Documentation
¶
Overview ¶
Package backendconformance is a reusable test suite that a config backend adapter runs against its own backend to prove it behaves like a first-class layer.
It is the remote-source counterpart of config/conformance, which does the same job for file codecs. A codec plugs into shared file machinery, so that suite can construct everything from a byte sample. A backend owns its whole interaction with a remote system — reading, versioning, staging, committing, watching — so this suite cannot build one itself. The adapter supplies a factory instead (Suite.NewBackend), plus a Control that stands in for another client of the same system: it changes the backing store out of band and re-opens a backend over it.
The trap this exists to make unmissable is the same one config/conformance guards for files, wearing a remote costume: the conflict fingerprint — here a version — must be captured at Load, not at Prepare. A backend that fetches a fresh version inside Prepare and compares Verify against that compares the intruder's data with itself, so every stale write is accepted and conflict detection silently never fires. The conflict_detected subtest catches it.
An adapter writes one test:
func TestConformance(t *testing.T) {
backendconformance.Run(t, backendconformance.Suite{
NewBackend: func(t *testing.T, seed map[string]any) (config.Backend, backendconformance.Control) {
remote := newFakeRemote(seed) // nil seed => absent source
return newConsulBackend(remote, "app/"), &control{remote}
},
Seed: map[string]any{"server": map[string]any{"port": 9090}},
Defines: map[string]string{"server.port": "9090"},
WriteKey: "server.port", WriteValue: "8080",
})
}
It uses the standard library testing package and nothing else — deliberately no testify — so an adapter that runs it takes on no assertion-library dependency it would otherwise avoid.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Run ¶
Run executes the whole suite against s, one named subtest per contract, so a failing adapter sees exactly which one it breaks.
Every backend is checked for per-key merge and precedence with a layer of another format, tolerance of an absent source, and provenance naming the backend. A config.WritableBackend additionally has its write round-trip and — the trap the suite exists for — its refusal of a change that landed between load and commit with config.ErrConflict checked; a read-only backend instead has its layer confirmed skipped by write routing — or, when it is sensitive (a secrets backend), the routed-beneath write confirmed refused with config.ErrSensitiveLeak. A writable backend also has a set of rendering and path-addressing hazards — control bytes, a bidi character, a numeric key, and gjson path metacharacters — confirmed to either round-trip exactly or fail closed with config.ErrBackendUnsafe or config.ErrBackendParse. A config.WatchableBackend has a foreign change confirmed to reach observers.
Types ¶
type Control ¶
type Control interface {
// Mutate changes the backing store as another client would: it moves the
// version the backend's conflict check compares against, and — for a
// watchable backend — emits the backend's change signal. It must change at
// least one value visible in the merged config, so a reload is not coalesced
// away as a no-op.
Mutate(t *testing.T)
// Reopen returns a fresh backend over the same backing store its NewBackend
// call created, so the suite can prove a committed write actually landed
// there rather than only in the Store's in-memory snapshot.
Reopen(t *testing.T) config.Backend
}
Control stands in for another client of the same backing store. It is how the suite simulates a foreign change — for the conflict and watch assertions — and how it re-reads the store to prove a committed write reached it.
type Suite ¶
type Suite struct {
// NewBackend builds a fresh backend over a fresh backing store seeded with
// the given nested values, plus a [Control] over that same store. A nil seed
// means an absent source: the backend's Load must report it not there
// (fs.ErrNotExist) rather than erroring. Each call must be independent — two
// calls must not share a backing store — except through the [Control], whose
// Reopen returns a backend over the store its NewBackend call created.
NewBackend func(t *testing.T, seed map[string]any) (config.Backend, Control)
// Seed is the nested values the backend is seeded with, and Defines is what
// they read back through a View. Both are required, with at least one key.
// Seed carries native types ({"server": {"port": 9090}}); Defines is the
// string each reads back (GetString), so the suite asserts decode, merge and
// provenance without knowing the system.
Seed map[string]any
Defines map[string]string
// WriteKey and WriteValue are a key the backend can set and the value it is
// set to — required only when NewBackend returns a [config.WritableBackend].
// The value must read back after the write.
WriteKey string
WriteValue string
// PinOnlyTargets declares that the backend's layers can receive a write only
// when a change NAMES them, and are never chosen by routing on their own.
//
// Writable normally implies routable, and for every backend that owns its
// own storage it does. A composed store is the exception: its layers belong
// to the store it wraps, and letting routing choose one would mean an
// ordinary edit of an inherited key rewriting the shared configuration it
// was inherited from. Promotion has to be named to happen.
//
// With this set, the write cases pin their target rather than relying on
// routing to find it, which is the only way such a backend can be written to
// at all.
PinOnlyTargets bool
// BoundedKeySpace declares that the backend accepts writes only to keys it
// was configured with, rather than to any key routed at it.
//
// Almost every backend takes arbitrary keys: a file, a Consul prefix or an
// object store will hold whatever it is given. Some cannot. A keychain has
// no way to enumerate itself, so an adapter over one is given an explicit
// map of config path to keychain account, and a key outside that map has no
// account to be written to — inventing one would put a secret somewhere no
// read would ever look for it.
//
// Setting this skips the hostile-KEY cases, which invent key names the
// backend cannot have been configured with. The hostile-VALUE cases still
// run against WriteKey, because a bounded key space says nothing about what
// values a backend must survive.
//
// Leave it false unless the backend genuinely refuses unconfigured keys. It
// removes coverage, and a backend that merely finds a key inconvenient
// should fail closed instead.
BoundedKeySpace bool
// NewUnreachable builds a backend whose connection cannot be established,
// plus a heal func that makes it establishable — the same backend, the same
// value, now able to connect.
//
// It exists because a backend that resolves its own connection has three
// obligations no other subtest reaches: that it can still say what it is
// before it has one, that failing to get one is an error rather than a panic,
// and that the failure is not remembered. The last is the one that bites: a
// backend memoising its connection with sync.OnceValues caches the first
// error forever, so a credential chain that was briefly unavailable at
// startup wedges the process until it restarts.
//
// Optional. A backend that owns no connection — a file, an in-memory store,
// one handed an already-built client — leaves it nil and the connection
// subtests are skipped. Supplying it is how an adapter with a zero-conf rung
// proves that rung behaves.
//
// The unreachable state must be reached without a network: point the backend
// at a fake whose dial fails, not at a real endpoint expected to be down.
NewUnreachable func(t *testing.T) (b config.Backend, heal func())
}
Suite describes a backend and the minimal knowledge the assertions need. The capability subtests — write and watch — run only when the backend the factory returns satisfies config.WritableBackend or config.WatchableBackend, so a read-only backend runs the read contract and no more.