Encryptor.Provider.Conformance behaviour (Encryptor v0.2.0)

Copy Markdown View Source

The shared test suite every Encryptor.Provider implementation is held to.

A provider is easy to write and easy to get subtly wrong: a candidate list in the wrong order, a fresh key minted on every call, an encryption key that is not among the candidates it will later have to be decrypted with. None of those fail loudly. They fail as an undecryptable row, months later, in someone else's deploy.

So the properties are written once, here, and every adapter runs them - including adapters in other packages, which is why this module ships in lib/ rather than in this repository's test/support.

Using it

defmodule MyApp.CardKeyProviderTest do
  use Encryptor.Provider.Conformance

  @impl true
  def provider_case do
    %{
      provider: MyApp.CardKeyProvider,
      opts: [root_key: :crypto.strong_rand_bytes(32)],
      selectors: ["merchant-42"],
      unknown: ["no-such-merchant"]
    }
  end
end

use Encryptor.Provider.Conformance brings in ExUnit.Case (with async: true unless the option list says otherwise) and the conformance tests. Put it at the top of the module, define provider_case/0, and add the adapter's own tests in the same module or a sibling one.

provider_case/0 is called afresh inside each test, so it may generate key material rather than holding it in a module attribute - a module attribute is compiled into a .beam file, which is what this package refuses to let a host do with a real key and is a poor habit to teach with a fixture one.

The keys of a case

  • :provider - the module under test. Required.
  • :opts - what the host would put in its :provider configuration. Required; may be [].
  • :selectors - selectors the provider is expected to serve. Defaults to [:default], which is what a single-key vault takes.
  • :unknown - selectors it is expected to refuse with {:unknown_key, _}. Defaults to [], because a provider that ignores the selector - which Encryptor.Provider.Static deliberately does - has none.

What it checks

  • The options resolve to a state through Encryptor.Provider.init/2, whether or not the provider exports Encryptor.Provider.init/1.
  • Encryptor.Provider.encryption_key/2 answers a descriptor the vault can build a keyring from, and Encryptor.Provider.decryption_keys/2 answers a non-empty candidate list of them. "The vault can build a keyring from it" is the real bar and it is checked by asking the vault's builder, not by re-implementing its rules.
  • The encryption key is the head of the candidate list. The candidates are newest first and the encryption key is the current one, so a provider whose head is something else will write messages it cannot read back through its own decrypt path.
  • Candidate names are distinct. A name is bound to its bytes forever; two entries sharing one is the failure the name contract exists to prevent.
  • Resolution is stable. Two calls with the same state and selector return the same descriptor. A provider that mints material on demand fails here, which is the point: key creation is a rotation procedure, not a side effect of being asked.
  • The candidate list builds the right keyring. One element builds a bare RawAes; more than one builds a Multi with generator: nil and one child per candidate, in the same order.
  • An unserved selector, when the case names one, is {:unknown_key, selector} - a settled negative answer, never {:key_unavailable, _} and never a raise.

What it does not check

The obligations that are documented rather than enforced: that resolution is bounded, that a provider adds no unbounded cache of its own, that a name is never reused across a deploy. A test suite that ran once cannot see any of them.

Summary

Types

What an adapter's test module describes about itself.

Callbacks

Describes the provider under test: the module, the options a host would configure it with, and the selectors it does and does not serve.

Functions

Asserts the vault's mapping from a candidate list to one keyring: a bare RawAes for one candidate, a Multi with generator: nil for more.

Asserts Encryptor.Provider.decryption_keys/2 answers a non-empty list of buildable descriptors.

Asserts no two candidates share a name.

Asserts Encryptor.Provider.encryption_key/2 answers a buildable descriptor.

Asserts the encryption key is the head of the candidate list.

Asserts both callbacks answer identically when asked twice.

Resolves the case's options into provider state, and returns it.

Asserts every selector the case names as unserved resolves to a settled {:unknown_key, selector} on both callbacks.

Types

case_spec()

@type case_spec() :: %{
  :provider => module(),
  :opts => keyword(),
  optional(:selectors) => [Encryptor.Provider.selector(), ...],
  optional(:unknown) => [Encryptor.Provider.selector()]
}

What an adapter's test module describes about itself.

Callbacks

provider_case()

@callback provider_case() :: case_spec()

Describes the provider under test: the module, the options a host would configure it with, and the selectors it does and does not serve.

Functions

assert_candidate_keyring(spec)

@spec assert_candidate_keyring(case_spec()) :: :ok

Asserts the vault's mapping from a candidate list to one keyring: a bare RawAes for one candidate, a Multi with generator: nil for more.

assert_decryption_keys(spec)

@spec assert_decryption_keys(case_spec()) :: :ok

Asserts Encryptor.Provider.decryption_keys/2 answers a non-empty list of buildable descriptors.

assert_distinct_names(spec)

@spec assert_distinct_names(case_spec()) :: :ok

Asserts no two candidates share a name.

assert_encryption_key(spec)

@spec assert_encryption_key(case_spec()) :: :ok

Asserts Encryptor.Provider.encryption_key/2 answers a buildable descriptor.

assert_encryption_key_is_head(spec)

@spec assert_encryption_key_is_head(case_spec()) :: :ok

Asserts the encryption key is the head of the candidate list.

Not merely a member of it: the list is newest first and the encryption key is the current one, so anything else means writes go under a key that is not the newest the provider admits to.

assert_stable(spec)

@spec assert_stable(case_spec()) :: :ok

Asserts both callbacks answer identically when asked twice.

A provider that generates material on demand fails here, and a provider that reads a store that changed under it during one test would too - which is what "stable until something outside the vault changes" means.

assert_state(spec)

@spec assert_state(case_spec()) :: Encryptor.Provider.state()

Resolves the case's options into provider state, and returns it.

Every other assertion below starts here, so a provider whose Encryptor.Provider.init/1 refuses its own documented options fails once rather than eight times.

assert_unknown_selectors(spec)

@spec assert_unknown_selectors(case_spec()) :: :ok

Asserts every selector the case names as unserved resolves to a settled {:unknown_key, selector} on both callbacks.

A case that names none passes trivially, which is correct for a provider that ignores the selector.