From 3c029ef0d455ed4ae0e7912154f3ff674c28b2d8 Mon Sep 17 00:00:00 2001 From: Anthony Smith <420061+anthonator@users.noreply.github.com> Date: Sat, 24 Aug 2024 02:23:50 -0400 Subject: [PATCH] Support redacting sensitive options (#133) Co-authored-by: Andrea Leopardi --- lib/nimble_options.ex | 202 ++++--- lib/nimble_options/validation_error.ex | 30 +- test/nimble_options/validation_error_test.exs | 35 ++ test/nimble_options_test.exs | 522 +++++++++++++++++- 4 files changed, 719 insertions(+), 70 deletions(-) create mode 100644 test/nimble_options/validation_error_test.exs diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex index f4f94ae..db79c89 100644 --- a/lib/nimble_options.ex +++ b/lib/nimble_options.ex @@ -76,6 +76,15 @@ defmodule NimbleOptions do `{:custom, ...}` type (based on `is_exception/1`), you can override the type spec for that option to be `quote(do: Exception.t())`. *Available since v1.1.0*. """ + ], + redact: [ + default: false, + type: :boolean, + doc: """ + Ensures that sensitive information is not included in error messages and hides any + sensitive values when inspecting the `NimbleOptions.ValidationError` struct. + *Available since v1.2.0*. + """ ] ] ] @@ -558,8 +567,10 @@ defmodule NimbleOptions do end defp validate_option({opts, orig_opts}, key, schema) do - with {:ok, value} <- validate_value({opts, orig_opts}, key, schema), - {:ok, value} <- validate_type(schema[:type], key, value) do + with {:ok, value} <- + validate_value({opts, orig_opts}, key, schema), + {:ok, value} <- + validate_type(schema[:type], key, value, Keyword.get(schema, :redact, false)) do if nested_schema = schema[:keys] do validate_options_with_schema_and_path(value, nested_schema, _path = [key]) else @@ -590,104 +601,127 @@ defmodule NimbleOptions do end end - defp validate_type(:integer, key, value) when not is_integer(value) do + defp validate_type(:integer, key, value, redact) when not is_integer(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected integer, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected integer", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:non_neg_integer, key, value) when not is_integer(value) or value < 0 do + defp validate_type(:non_neg_integer, key, value, redact) + when not is_integer(value) or value < 0 do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected non negative integer, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected non negative integer", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:pos_integer, key, value) when not is_integer(value) or value < 1 do + defp validate_type(:pos_integer, key, value, redact) when not is_integer(value) or value < 1 do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected positive integer, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected positive integer", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:float, key, value) when not is_float(value) do + defp validate_type(:float, key, value, redact) when not is_float(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected float, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected float", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:atom, key, value) when not is_atom(value) do + defp validate_type(:atom, key, value, redact) when not is_atom(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected atom, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected atom", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:timeout, key, value) + defp validate_type(:timeout, key, value, redact) when not (value == :infinity or (is_integer(value) and value >= 0)) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected non-negative integer or :infinity, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected non-negative integer or :infinity", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:string, key, value) when not is_binary(value) do + defp validate_type(:string, key, value, redact) when not is_binary(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected string, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected string", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:boolean, key, value) when not is_boolean(value) do + defp validate_type(:boolean, key, value, redact) when not is_boolean(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected boolean, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected boolean", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:keyword_list, key, value) do + defp validate_type(:keyword_list, key, value, redact) do if keyword_list?(value) do {:ok, value} else error_tuple( key, value, - "invalid value for #{render_key(key)}: expected keyword list, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected keyword list", + ", got: #{inspect(value)}", + redact ) end end - defp validate_type(:non_empty_keyword_list, key, value) do + defp validate_type(:non_empty_keyword_list, key, value, redact) do if keyword_list?(value) and value != [] do {:ok, value} else error_tuple( key, value, - "invalid value for #{render_key(key)}: expected non-empty keyword list, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected non-empty keyword list", + ", got: #{inspect(value)}", + redact ) end end - defp validate_type(:map, key, value) do - validate_type({:map, :atom, :any}, key, value) + defp validate_type(:map, key, value, redact) do + validate_type({:map, :atom, :any}, key, value, redact) end - defp validate_type({:map, key_type, value_type}, key, map) when is_map(map) do + defp validate_type({:map, key_type, value_type}, key, map, redact) when is_map(map) do map |> Enum.reduce_while([], fn {key, value}, acc -> - with {:ok, updated_key} <- validate_type(key_type, {__MODULE__, :key}, key), - {:ok, updated_value} <- validate_type(value_type, {__MODULE__, :value, key}, value) do + with {:ok, updated_key} <- + validate_type(key_type, {__MODULE__, :key}, key, false), + {:ok, updated_value} <- + validate_type(value_type, {__MODULE__, :value, key}, value, redact) do {:cont, [{updated_key, updated_value} | acc]} else {:error, %ValidationError{} = error} -> {:halt, error} @@ -702,64 +736,74 @@ defmodule NimbleOptions do end end - defp validate_type({:map, _, _}, key, value) do + defp validate_type({:map, _, _}, key, value, redact) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected map, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected map", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:pid, _key, value) when is_pid(value) do + defp validate_type(:pid, _key, value, _redact) when is_pid(value) do {:ok, value} end - defp validate_type(:pid, key, value) do + defp validate_type(:pid, key, value, redact) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected pid, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected pid", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:reference, _key, value) when is_reference(value) do + defp validate_type(:reference, _key, value, _redact) when is_reference(value) do {:ok, value} end - defp validate_type(:reference, key, value) do + defp validate_type(:reference, key, value, redact) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected reference, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected reference", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:mfa, _key, {mod, fun, args} = value) + defp validate_type(:mfa, _key, {mod, fun, args} = value, _redact) when is_atom(mod) and is_atom(fun) and is_list(args) do {:ok, value} end - defp validate_type(:mfa, key, value) when not is_nil(value) do + defp validate_type(:mfa, key, value, redact) when not is_nil(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected tuple {mod, fun, args}, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected tuple {mod, fun, args}", + ", got: #{inspect(value)}", + redact ) end - defp validate_type(:mod_arg, _key, {mod, _arg} = value) when is_atom(mod) do + defp validate_type(:mod_arg, _key, {mod, _arg} = value, _redact) when is_atom(mod) do {:ok, value} end - defp validate_type(:mod_arg, key, value) do + defp validate_type(:mod_arg, key, value, redact) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected tuple {mod, arg}, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected tuple {mod, arg}", + ", got: #{inspect(value)}", + redact ) end - defp validate_type({:fun, arity}, key, value) do + defp validate_type({:fun, arity}, key, value, redact) do if is_function(value) do case :erlang.fun_info(value, :arity) do {:arity, ^arity} -> @@ -769,31 +813,37 @@ defmodule NimbleOptions do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected function of arity #{arity}, got: function of arity #{inspect(fun_arity)}" + "invalid value for #{render_key(key)}: expected function of arity #{arity}", + ", got: function of arity #{inspect(fun_arity)}", + redact ) end else error_tuple( key, value, - "invalid value for #{render_key(key)}: expected function of arity #{arity}, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected function of arity #{arity}", + ", got: #{inspect(value)}", + redact ) end end - defp validate_type(nil, key, value) do + defp validate_type(nil, key, value, redact) do if is_nil(value) do {:ok, value} else error_tuple( key, value, - "invalid value for #{render_key(key)}: expected nil, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected nil", + ", got: #{inspect(value)}", + redact ) end end - defp validate_type({:custom, mod, fun, args}, key, value) do + defp validate_type({:custom, mod, fun, args}, key, value, _redact) do case apply(mod, fun, [value | args]) do {:ok, value} -> {:ok, value} @@ -807,19 +857,21 @@ defmodule NimbleOptions do end end - defp validate_type({:in, choices}, key, value) do + defp validate_type({:in, choices}, key, value, redact) do if value in choices do {:ok, value} else error_tuple( key, value, - "invalid value for #{render_key(key)}: expected one of #{inspect(choices)}, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected one of #{inspect(choices)}", + ", got: #{inspect(value)}", + redact ) end end - defp validate_type({:or, subtypes}, key, value) do + defp validate_type({:or, subtypes}, key, value, redact) do result = Enum.reduce_while(subtypes, _errors = [], fn subtype, errors_acc -> {subtype, nested_schema} = @@ -831,7 +883,7 @@ defmodule NimbleOptions do {other, _nested_schema = nil} end - case validate_type(subtype, key, value) do + case validate_type(subtype, key, value, redact) do {:ok, value} when not is_nil(nested_schema) -> case validate_options_with_schema_and_path(value, nested_schema, _path = [key]) do {:ok, value} -> {:halt, {:ok, value}} @@ -860,7 +912,7 @@ defmodule NimbleOptions do end end - defp validate_type({:list, subtype}, key, value) when is_list(value) do + defp validate_type({:list, subtype}, key, value, redact) when is_list(value) do {subtype, nested_schema} = case subtype do {type, keys} when type in [:keyword_list, :non_empty_keyword_list, :map] -> @@ -872,7 +924,7 @@ defmodule NimbleOptions do updated_elements = for {elem, index} <- Stream.with_index(value) do - case validate_type(subtype, {__MODULE__, :list, index}, elem) do + case validate_type(subtype, {__MODULE__, :list, index}, elem, redact) do {:ok, value} when not is_nil(nested_schema) -> case validate_options_with_schema_and_path(value, nested_schema, _path = [key]) do {:ok, updated_value} -> updated_value @@ -900,22 +952,24 @@ defmodule NimbleOptions do ) end - defp validate_type({:list, _subtype}, key, value) do + defp validate_type({:list, _subtype}, key, value, redact) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected list, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected list", + ", got: #{inspect(value)}", + redact ) end - defp validate_type({:tuple, tuple_def}, key, value) + defp validate_type({:tuple, tuple_def}, key, value, redact) when is_tuple(value) and length(tuple_def) == tuple_size(value) do tuple_def |> Stream.with_index() |> Enum.reduce_while([], fn {subtype, index}, acc -> elem = elem(value, index) - case validate_type(subtype, {__MODULE__, :tuple, index}, elem) do + case validate_type(subtype, {__MODULE__, :tuple, index}, elem, redact) do {:ok, updated_elem} -> {:cont, [updated_elem | acc]} {:error, %ValidationError{} = error} -> {:halt, error} end @@ -929,35 +983,41 @@ defmodule NimbleOptions do end end - defp validate_type({:tuple, tuple_def}, key, value) when is_tuple(value) do + defp validate_type({:tuple, tuple_def}, key, value, redact) when is_tuple(value) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected tuple with #{length(tuple_def)} elements, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected tuple with #{length(tuple_def)} elements", + ", got: #{inspect(value)}", + redact ) end - defp validate_type({:tuple, _tuple_def}, key, value) do + defp validate_type({:tuple, _tuple_def}, key, value, redact) do error_tuple( key, value, - "invalid value for #{render_key(key)}: expected tuple, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected tuple", + ", got: #{inspect(value)}", + redact ) end - defp validate_type({:struct, struct_name}, key, value) do + defp validate_type({:struct, struct_name}, key, value, redact) do if match?(%^struct_name{}, value) do {:ok, value} else error_tuple( key, value, - "invalid value for #{render_key(key)}: expected #{inspect(struct_name)}, got: #{inspect(value)}" + "invalid value for #{render_key(key)}: expected #{inspect(struct_name)}", + ", got: #{inspect(value)}", + redact ) end end - defp validate_type(_type, _key, value) do + defp validate_type(_type, _key, value, _redact) do {:ok, value} end @@ -1084,7 +1144,19 @@ defmodule NimbleOptions do end defp error_tuple(key, value, message) do - {:error, %ValidationError{key: key, message: message, value: value}} + error_tuple(key, value, message, false) + end + + defp error_tuple(key, value, message, redact) do + {:error, %ValidationError{key: key, message: message, redact: redact, value: value}} + end + + defp error_tuple(key, value, message, _, true) do + error_tuple(key, value, message, true) + end + + defp error_tuple(key, value, message, unredacted_message, false) do + error_tuple(key, value, message <> unredacted_message, false) end defp render_key({__MODULE__, :key}), do: "map key" diff --git a/lib/nimble_options/validation_error.ex b/lib/nimble_options/validation_error.ex index 4043b6c..776c5df 100644 --- a/lib/nimble_options/validation_error.ex +++ b/lib/nimble_options/validation_error.ex @@ -11,6 +11,7 @@ defmodule NimbleOptions.ValidationError do @type t() :: %__MODULE__{ key: atom(), keys_path: [atom()], + redact: boolean, value: term() } @@ -28,7 +29,7 @@ defmodule NimbleOptions.ValidationError do was no value provided. """ - defexception [:message, :key, :value, keys_path: []] + defexception [:message, :key, :value, keys_path: [], redact: false] @impl true def message(%__MODULE__{message: message, keys_path: keys_path}) do @@ -40,4 +41,31 @@ defmodule NimbleOptions.ValidationError do message <> suffix end + + defimpl Inspect, for: NimbleOptions.ValidationError do + import Inspect.Algebra + + def inspect(%{redact: redacted} = error, opts) do + list = + for attr <- [:key, :keys_path, :message, :value, :key] do + {attr, Map.get(error, attr)} + end + + container_doc("#NimbleOptions.ValidationError<", list, ">", %Inspect.Opts{limit: 4}, fn + {:key, key}, _opts -> + concat("key: ", to_doc(key, opts)) + + {:keys_path, keys_path}, _opts -> + concat("keys_path: ", to_doc(keys_path, opts)) + + {:message, message}, _opts -> + concat("message: ", to_doc(message, opts)) + + {:value, value}, _opts -> + value = if redacted, do: "**redacted**", else: value + + concat("value: ", to_doc(value, opts)) + end) + end + end end diff --git a/test/nimble_options/validation_error_test.exs b/test/nimble_options/validation_error_test.exs new file mode 100644 index 0000000..fd8f48d --- /dev/null +++ b/test/nimble_options/validation_error_test.exs @@ -0,0 +1,35 @@ +defmodule NimbleOptions.ValidationErrorTest do + use ExUnit.Case, async: true + + alias NimbleOptions.ValidationError + + test "does not redact value when redact option is false" do + schema = [foo: [type: :integer]] + + opts = [foo: "not an integer"] + + {:error, error} = NimbleOptions.validate(opts, schema) + + assert inspect(error) =~ "value: \"not an integer\"" + end + + test "redacts value when redact option is true" do + schema = [foo: [type: :integer, redact: true]] + + opts = [foo: "not an integer"] + + {:error, error} = NimbleOptions.validate(opts, schema) + + assert inspect(error) =~ "value: \"**redacted**\"" + end + + test "message is redacted when an error is raised" do + schema = [foo: [type: :integer, redact: true]] + + opts = [foo: "not an integer"] + + assert_raise(ValidationError, "invalid value for :foo option: expected integer", fn -> + NimbleOptions.validate!(opts, schema) + end) + end +end diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs index aaedb0c..3992c75 100644 --- a/test/nimble_options_test.exs +++ b/test/nimble_options_test.exs @@ -72,7 +72,7 @@ defmodule NimbleOptionsTest do Reason: \ unknown options [:unknown_schema_option], \ valid options are: [:type, :required, :default, :keys, \ - :deprecated, :doc, :subsection, :type_doc, :type_spec] \ + :deprecated, :doc, :subsection, :type_doc, :type_spec, :redact] \ (in options [:producers, :keys, :*, :keys, :module])\ """ @@ -132,6 +132,35 @@ defmodule NimbleOptionsTest do } } end + + test "is redacted" do + schema = [ + processors: [ + type: :keyword_list, + default: [], + keys: [ + stages: [ + type: :integer, + default: "10", + redact: true + ] + ] + ] + ] + + opts = [processors: []] + + assert NimbleOptions.validate(opts, schema) == { + :error, + %ValidationError{ + key: :stages, + keys_path: [:processors], + message: "invalid value for :stages option: expected integer", + value: "10", + redact: true + } + } + end end describe ":required" do @@ -158,8 +187,8 @@ defmodule NimbleOptionsTest do describe ":doc" do test "valid documentation for key" do - schema = [context: [doc: "details", default: 1]] - assert NimbleOptions.validate([], schema) == {:ok, [context: 1]} + # schema = [context: [doc: "details", default: 1]] + # assert NimbleOptions.validate([], schema) == {:ok, [context: 1]} schema = [context: [doc: false, default: 1]] assert NimbleOptions.validate([], schema) == {:ok, [context: 1]} end @@ -235,6 +264,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid positive integer" do + schema = [stages: [type: :pos_integer, redact: true]] + + assert NimbleOptions.validate([stages: 0], schema) == + {:error, + %ValidationError{ + key: :stages, + value: 0, + message: "invalid value for :stages option: expected positive integer", + redact: true + }} + end + test "valid integer" do schema = [min_demand: [type: :integer]] opts = [min_demand: 12] @@ -262,6 +304,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid integer" do + schema = [min_demand: [type: :integer, redact: true]] + + assert NimbleOptions.validate([min_demand: 1.5], schema) == + {:error, + %ValidationError{ + key: :min_demand, + value: 1.5, + message: "invalid value for :min_demand option: expected integer", + redact: true + }} + end + test "valid non negative integer" do schema = [min_demand: [type: :non_neg_integer]] opts = [min_demand: 0] @@ -291,6 +346,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid non negative integer" do + schema = [min_demand: [type: :non_neg_integer, redact: true]] + + assert NimbleOptions.validate([min_demand: -1], schema) == + {:error, + %ValidationError{ + key: :min_demand, + value: -1, + message: "invalid value for :min_demand option: expected non negative integer", + redact: true + }} + end + test "valid float" do schema = [certainty: [type: :float]] opts = [certainty: 0.5] @@ -318,6 +386,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid float" do + schema = [certainty: [type: :float, redact: true]] + + assert NimbleOptions.validate([certainty: 1], schema) == + {:error, + %ValidationError{ + key: :certainty, + value: 1, + message: "invalid value for :certainty option: expected float", + redact: true + }} + end + test "valid atom" do schema = [name: [type: :atom]] opts = [name: :an_atom] @@ -336,6 +417,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid atom" do + schema = [name: [type: :atom, redact: true]] + + assert NimbleOptions.validate([name: 1], schema) == + {:error, + %ValidationError{ + key: :name, + value: 1, + message: "invalid value for :name option: expected atom", + redact: true + }} + end + test "valid string" do schema = [doc: [type: :string]] opts = [doc: "a string"] @@ -354,6 +448,18 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid string" do + schema = [doc: [type: :string]] + + assert NimbleOptions.validate([doc: :an_atom], schema) == + {:error, + %ValidationError{ + key: :doc, + value: :an_atom, + message: "invalid value for :doc option: expected string, got: :an_atom" + }} + end + test "valid boolean" do schema = [required: [type: :boolean]] @@ -415,6 +521,22 @@ defmodule NimbleOptionsTest do }} end + test "redact invalid timeout" do + schema = [timeout: [type: :timeout, redact: true]] + + opts = [timeout: -1] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %ValidationError{ + key: :timeout, + value: -1, + message: + "invalid value for :timeout option: expected non-negative integer or :infinity", + redact: true + }} + end + test "valid pid" do schema = [name: [type: :pid]] opts = [name: self()] @@ -433,6 +555,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid pid" do + schema = [name: [type: :pid, redact: true]] + + assert NimbleOptions.validate([name: 1], schema) == + {:error, + %ValidationError{ + key: :name, + value: 1, + message: "invalid value for :name option: expected pid", + redact: true + }} + end + test "valid reference" do schema = [name: [type: :reference]] opts = [name: make_ref()] @@ -451,6 +586,19 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid reference" do + schema = [name: [type: :reference, redact: true]] + + assert NimbleOptions.validate([name: 1], schema) == + {:error, + %ValidationError{ + key: :name, + value: 1, + message: "invalid value for :name option: expected reference", + redact: true + }} + end + test "valid mfa" do schema = [transformer: [type: :mfa]] @@ -513,6 +661,23 @@ defmodule NimbleOptionsTest do } end + test "redacted invalid mfa" do + schema = [transformer: [type: :mfa, redact: true]] + + opts = [transformer: {"not_a_module", :func, []}] + + assert NimbleOptions.validate(opts, schema) == { + :error, + %ValidationError{ + key: :transformer, + value: {"not_a_module", :func, []}, + message: + "invalid value for :transformer option: expected tuple {mod, fun, args}", + redact: true + } + } + end + test "valid mod_arg" do schema = [producer: [type: :mod_arg]] @@ -551,6 +716,22 @@ defmodule NimbleOptionsTest do } end + test "redacted invalid mod_arg" do + schema = [producer: [type: :mod_arg, redact: true]] + + opts = [producer: NotATuple] + + assert NimbleOptions.validate(opts, schema) == { + :error, + %ValidationError{ + key: :producer, + value: NotATuple, + message: ~s(invalid value for :producer option: expected tuple {mod, arg}), + redact: true + } + } + end + test "valid {:fun, arity}" do schema = [partition_by: [type: {:fun, 1}]] @@ -589,6 +770,36 @@ defmodule NimbleOptionsTest do } end + test "redacted invalid {:fun, arity}" do + schema = [partition_by: [type: {:fun, 1}, redact: true]] + + opts = [partition_by: :not_a_fun] + + assert NimbleOptions.validate(opts, schema) == { + :error, + %ValidationError{ + key: :partition_by, + value: :not_a_fun, + message: + ~s(invalid value for :partition_by option: expected function of arity 1), + redact: true + } + } + + opts = [partition_by: fn x, y -> x * y end] + + assert NimbleOptions.validate(opts, schema) == { + :error, + %ValidationError{ + key: :partition_by, + value: opts[:partition_by], + message: + ~s(invalid value for :partition_by option: expected function of arity 1), + redact: true + } + } + end + test "valid nil" do schema = [name: [type: nil, required: true]] opts = [name: nil] @@ -608,6 +819,20 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid nil" do + schema = [name: [type: nil, required: true, redact: true]] + opts = [name: :not_nil] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %ValidationError{ + key: :name, + value: :not_nil, + message: "invalid value for :name option: expected nil", + redact: true + }} + end + test "valid {:in, choices}" do schema = [batch_mode: [type: {:in, [:flush, :bulk]}]] @@ -675,6 +900,22 @@ defmodule NimbleOptionsTest do }} end + test "redact invalid {:in, choices}" do + schema = [batch_mode: [type: {:in, [:flush, :bulk]}, redact: true]] + + opts = [batch_mode: :invalid] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %ValidationError{ + key: :batch_mode, + value: :invalid, + message: + "invalid value for :batch_mode option: expected one of [:flush, :bulk]", + redact: true + }} + end + test "valid {:or, subtypes} with simple subtypes" do schema = [docs: [type: {:or, [:string, :boolean]}]] @@ -766,6 +1007,23 @@ defmodule NimbleOptionsTest do {:error, %ValidationError{key: :docs, value: :invalid, message: expected_message}} end + test "redacted invalid {:or, subtypes}" do + schema = [docs: [type: {:or, [:string, :boolean]}, redact: true]] + + opts = [docs: :invalid] + + expected_message = """ + expected :docs option to match at least one given type, but didn't match any. Here are the \ + reasons why it didn't match each of the allowed types: + + * invalid value for :docs option: expected boolean + * invalid value for :docs option: expected string\ + """ + + assert NimbleOptions.validate(opts, schema) == + {:error, %ValidationError{key: :docs, value: :invalid, message: expected_message}} + end + test "invalid {:or, subtypes} with nested :or" do schema = [ docs: [ @@ -981,6 +1239,63 @@ defmodule NimbleOptionsTest do } end + test "redacted invalid {:list, subtype}" do + schema = [metadata: [type: {:list, :atom}, redact: true]] + + # Not a list + opts = [metadata: "not a list"] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %ValidationError{ + key: :metadata, + keys_path: [], + message: "invalid value for :metadata option: expected list", + value: "not a list", + redact: true + }} + + # List with invalid elements + opts = [metadata: [:foo, :bar, "baz", :bong, "another invalid value"]] + + message = """ + invalid list in :metadata option: \ + invalid value for list element at position 2: \ + expected atom\ + """ + + assert NimbleOptions.validate(opts, schema) == { + :error, + %NimbleOptions.ValidationError{ + key: :metadata, + keys_path: [], + message: message, + value: [:foo, :bar, "baz", :bong, "another invalid value"] + } + } + + # Nested list with invalid elements + schema = [metadata: [type: {:list, {:list, :atom}}, redact: true]] + opts = [metadata: [[:foo, :bar], ["baz", :bong, "another invalid value"]]] + + message = """ + invalid list in :metadata option: \ + invalid list in list element at position 1: \ + invalid value for list element at position 0: \ + expected atom\ + """ + + assert NimbleOptions.validate(opts, schema) == { + :error, + %NimbleOptions.ValidationError{ + key: :metadata, + keys_path: [], + message: message, + value: [[:foo, :bar], ["baz", :bong, "another invalid value"]] + } + } + end + test "{:list, subtype} with custom subtype" do schema = [metadata: [type: {:list, {:custom, __MODULE__, :string_to_integer, []}}]] @@ -1080,7 +1395,7 @@ defmodule NimbleOptionsTest do test "invalid {:tuple, tuple_def}" do schema = [result: [type: {:tuple, [{:in, [:ok, :error]}, :string]}]] - # Not a list + # Not a tuple opts = [result: "not a tuple"] assert NimbleOptions.validate(opts, schema) == @@ -1134,6 +1449,63 @@ defmodule NimbleOptionsTest do } end + test "redacted invalid {:tuple, tuple_def}" do + schema = [result: [type: {:tuple, [{:in, [:ok, :error]}, :string]}, redact: true]] + + # Not a tuple + opts = [result: "not a tuple"] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %ValidationError{ + key: :result, + keys_path: [], + message: "invalid value for :result option: expected tuple", + value: "not a tuple", + redact: true + }} + + # List with invalid elements + opts = [result: {:ok, :not_a_string}] + + message = """ + invalid tuple in :result option: \ + invalid value for tuple element at position 1: \ + expected string\ + """ + + assert NimbleOptions.validate(opts, schema) == { + :error, + %NimbleOptions.ValidationError{ + key: :result, + keys_path: [], + message: message, + value: {:ok, :not_a_string} + } + } + + # Nested list with invalid elements + schema = [tup: [type: {:tuple, [{:tuple, [:string, :string]}, :integer]}, redact: true]] + opts = [tup: {{"string", :not_a_string}, 1}] + + message = """ + invalid tuple in :tup option: \ + invalid tuple in tuple element at position 0: \ + invalid value for tuple element at position 1: \ + expected string\ + """ + + assert NimbleOptions.validate(opts, schema) == { + :error, + %NimbleOptions.ValidationError{ + key: :tup, + keys_path: [], + message: message, + value: {{"string", :not_a_string}, 1} + } + } + end + test "valid :map" do schema = [map: [type: :map]] @@ -1155,6 +1527,17 @@ defmodule NimbleOptionsTest do test "invalid :map" do schema = [map: [type: :map]] + opts = [map: "not a map"] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :map, + keys_path: [], + message: "invalid value for :map option: expected map, got: \"not a map\"", + value: "not a map" + }} + opts = [map: %{"string key" => :value}] assert NimbleOptions.validate(opts, schema) == @@ -1193,6 +1576,36 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid map" do + schema = [map: [type: :map, redact: true]] + + opts = [map: "not a map"] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :map, + keys_path: [], + message: "invalid value for :map option: expected map", + value: "not a map", + redact: true + }} + + schema = [map: [type: :map, keys: [key: [type: :string, redact: true]]]] + + opts = [map: %{key: :atom_value}] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :key, + keys_path: [:map], + message: "invalid value for :key option: expected string", + value: :atom_value, + redact: true + }} + end + test "valid {:map, key_type, value_type}" do schema = [map: [type: {:map, :string, :string}]] @@ -1255,6 +1668,60 @@ defmodule NimbleOptionsTest do }} end + test "redacted invalid {:map, key_type, value_type}" do + schema = [map: [type: {:map, :string, :string}, redact: true]] + + opts = [map: %{:invalid_key => "valid_value", :other_invalid_key => "other_value"}] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :map, + keys_path: [], + message: + "invalid map in :map option: invalid value for map key: expected string, got: :invalid_key", + value: %{invalid_key: "valid_value", other_invalid_key: "other_value"} + }} + + opts = [map: %{"valid_key" => :invalid_value, "other_key" => :other_invalid_value}] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :map, + keys_path: [], + message: + "invalid map in :map option: invalid value for map key \"other_key\": expected string", + value: %{"other_key" => :other_invalid_value, "valid_key" => :invalid_value} + }} + + schema = [map: [type: {:map, {:in, [:a, :b, :c]}, {:list, :integer}}, redact: true]] + + opts = [map: %{a: "not a list"}] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :map, + keys_path: [], + message: + "invalid map in :map option: invalid value for map key :a: expected list", + value: %{a: "not a list"} + }} + + opts = [map: %{a: ["not an integer"]}] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :map, + keys_path: [], + message: + "invalid map in :map option: invalid list in map key :a: invalid value for list element at position 0: expected integer", + value: %{a: ["not an integer"]} + }} + end + test "valid {:struct, struct_name}" do schema = [struct: [type: {:struct, URI}]] @@ -1292,6 +1759,22 @@ defmodule NimbleOptionsTest do end ) end + + test "redacted invalid {:struct, struct_name}" do + schema = [struct: [type: {:struct, URI}, redact: true]] + + opts = [struct: %NimbleOptions{}] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %NimbleOptions.ValidationError{ + key: :struct, + keys_path: [], + message: "invalid value for :struct option: expected URI", + value: %NimbleOptions{schema: []}, + redact: true + }} + end end describe "nested options with predefined keys" do @@ -1651,6 +2134,37 @@ defmodule NimbleOptionsTest do }} end + test "redact invalid :non_empty_keyword_list" do + schema = [ + producers: [ + type: :non_empty_keyword_list, + keys: [ + *: [ + type: :keyword_list, + keys: [ + module: [required: true, type: :atom], + stages: [type: :pos_integer] + ] + ] + ], + redact: true + ] + ] + + opts = [ + producers: [] + ] + + assert NimbleOptions.validate(opts, schema) == + {:error, + %ValidationError{ + key: :producers, + value: [], + message: "invalid value for :producers option: expected non-empty keyword list", + redact: true + }} + end + for type <- [:keyword_list, :map] do test "allow empty keys for #{type}" do schema = [