| new file mode 100644 |
| index 000000000..ec8197ceb |
| --- /dev/null |
| +++ b/internal/appconfig/canprovide_test.go |
| @@ -0,0 +1,268 @@ |
| +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) |
| + } |
| +} |
| + |
| +// One key opens every envelope in a deployment, so one that opens proves the |
| +// key. A single stale or corrupt secret next to it is the sync loop's fault |
| +// to report, not a reason to refuse every wake of a keyed box. |
| +func TestCanProvideConfigOneOpenableEnvelopeProvesTheKey(t *testing.T) { |
| + e := newCanProvideEnv(t) |
| + ctx := context.Background() |
| + foreign, err := SealUnbound(e.other.PublicKey(), []byte("z")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{ |
| + "A": {Kind: "secret", Envelope: foreign}, |
| + "B": {Kind: "secret", Envelope: "not-base64!!"}, |
| + "C": e.secret("ok"), |
| + }}) |
| + if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { |
| + t.Fatalf("one good envelope among bad ones: %v", err) |
| + } |
| + // All bad: no proof. |
| + e.put(Key("bab", "bad"), Manifest{App: "bad", Vars: map[string]Var{"A": {Kind: "secret", Envelope: foreign}}}) |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "bad", "", "a.example.com")); got != ReasonCannotOpen { |
| + t.Errorf("all-foreign reason = %q", 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: |
| + 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) |
| + } |
| +} |