diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 06dfff2e..62f347f2 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -3,5 +3,6 @@ ~r/Function :asn1ct.compile\/2 does not exist/, ~r/lib\/mix\//, ~r/test\/support\/mix\//, + ~r/test\/support\/requests_generator\.ex/, {"test/support/api.ex"} ] diff --git a/lib/mongoose_push_web/plugs/cast_and_validate.ex b/lib/mongoose_push_web/plugs/cast_and_validate.ex index c94bd889..f8b9b918 100644 --- a/lib/mongoose_push_web/plugs/cast_and_validate.ex +++ b/lib/mongoose_push_web/plugs/cast_and_validate.ex @@ -34,6 +34,19 @@ defmodule MongoosePushWeb.Plug.CastAndValidate do # "Failed to cast value to one of: [] (no schemas provided)", instead of something more meaningful. # Here we avoid it by manual modification of the pattern the request is matched against, # so the framework can serve us as expected. + defp update_schema_and_do_call( + conn = %{params: %{"alert" => _, "data" => _}}, + opts = %{operation_id: operation_id} + ) do + new_schema = %OpenApiSpex.Reference{ + "$ref": "#/components/schemas/Request.SendNotification.Deep.MixedNotification" + } + + conn + |> update_schema(operation_id, new_schema) + |> OpenApiSpex.Plug.CastAndValidate.call(opts) + end + defp update_schema_and_do_call( conn = %{params: %{"data" => _}}, opts = %{operation_id: operation_id} diff --git a/mix.exs b/mix.exs index 45857126..5acb9931 100644 --- a/mix.exs +++ b/mix.exs @@ -61,7 +61,8 @@ defmodule MongoosePush.Mixfile do {:telemetry, "~>0.4.1"}, {:telemetry_metrics, "~> 0.5"}, {:telemetry_metrics_prometheus_core, "~> 0.4"}, - {:logfmt, "~>3.3"} + {:logfmt, "~>3.3"}, + {:stream_data, "~> 0.5", only: :test} ] end diff --git a/mix.lock b/mix.lock index 2722e28a..8fa28d31 100644 --- a/mix.lock +++ b/mix.lock @@ -50,6 +50,7 @@ "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, "sparrow": {:git, "https://github.com/esl/sparrow.git", "b1896ca4fb0ca18369dd62de3d4c82f14f5bc66e", [ref: "b1896ca"]}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "0.4.0", "0860e53746f4554cf453a5217a3d2648a6d3a074ae01a21869a3963c54b1d5bc", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.5", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "912e4c4421477bfb930a19a8de5b2eb967c2700880698c6d80706b8bc32532bf"}, diff --git a/test/mongoose_push_web/controllers/api_v1_notification_controller_test.exs b/test/mongoose_push_web/controllers/api_v1_notification_controller_test.exs index bb6fbdb6..5166196b 100644 --- a/test/mongoose_push_web/controllers/api_v1_notification_controller_test.exs +++ b/test/mongoose_push_web/controllers/api_v1_notification_controller_test.exs @@ -30,7 +30,7 @@ defmodule MongoosePushWeb.APIv1NotificationControllerTest do conn = post(conn, "/v1/notification/666", body) assert json_response(conn, 422) == - ControllersHelper.missing_field_response(unquote(dropped)) + ControllersHelper.missing_field_response(:v1, unquote(dropped)) end end diff --git a/test/mongoose_push_web/controllers/api_v2_notification_controller_test.exs b/test/mongoose_push_web/controllers/api_v2_notification_controller_test.exs index 97884042..9cce0080 100644 --- a/test/mongoose_push_web/controllers/api_v2_notification_controller_test.exs +++ b/test/mongoose_push_web/controllers/api_v2_notification_controller_test.exs @@ -30,7 +30,7 @@ defmodule MongoosePushWeb.APIv2NotificationControllerTest do conn = post(conn, "/v2/notification/654321", body) assert json_response(conn, 422) == - ControllersHelper.missing_field_response(unquote(dropped)) + ControllersHelper.missing_field_response(:v2, unquote(dropped)) end end @@ -81,7 +81,7 @@ defmodule MongoosePushWeb.APIv2NotificationControllerTest do conn = post(conn, "/v2/notification/654321", body) assert json_response(conn, 422) == - ControllersHelper.missing_field_response(unquote(missing)) + ControllersHelper.missing_field_response(:v2, unquote(missing)) end end @@ -124,8 +124,8 @@ defmodule MongoosePushWeb.APIv2NotificationControllerTest do assert json_response(conn, 422) == Map.merge( - ControllersHelper.missing_field_response("service"), - ControllersHelper.missing_field_response("alert"), + ControllersHelper.missing_field_response(:v2, "service"), + ControllersHelper.missing_field_response(:v2, "alert"), fn _k, v1, v2 -> v1 ++ v2 end ) end diff --git a/test/mongoose_push_web/controllers/api_v3_notification_controller_test.exs b/test/mongoose_push_web/controllers/api_v3_notification_controller_test.exs index 89606188..6deefc5a 100644 --- a/test/mongoose_push_web/controllers/api_v3_notification_controller_test.exs +++ b/test/mongoose_push_web/controllers/api_v3_notification_controller_test.exs @@ -1,5 +1,7 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do alias MongoosePushWeb.Support.ControllersHelper + alias MongoosePushWeb.Support.RequestsGenerator + use ExUnitProperties use MongoosePushWeb.ConnCase, async: true import Mox @@ -31,7 +33,7 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do conn = post(conn, "/v3/notification/123456", body) assert json_response(conn, 422) == - ControllersHelper.missing_field_response(unquote(dropped)) + ControllersHelper.missing_field_response(:v3, unquote(dropped)) end end @@ -83,7 +85,7 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do conn = post(conn, "/v3/notification/123456", body) assert json_response(conn, 422) == - ControllersHelper.missing_field_response(unquote(missing)) + ControllersHelper.missing_field_response(:v3, unquote(missing)) end end @@ -126,8 +128,8 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do assert json_response(conn, 422) == Map.merge( - ControllersHelper.missing_field_response("service"), - ControllersHelper.missing_field_response("alert"), + ControllersHelper.missing_field_response(:v3, "service"), + ControllersHelper.missing_field_response(:v3, "alert"), fn _k, v1, v2 -> v1 ++ v2 end ) end @@ -816,6 +818,35 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do post_and_assert(conn, device_id, expected_device_id, request, expected_request) end + property "APIv3 notification decoder property-based test", %{conn: conn} do + check all( + mandatory <- RequestsGenerator.mandatory_fields(), + optionals <- RequestsGenerator.optional_fields(), + device_id <- RequestsGenerator.device_id() + ) do + request = Map.merge(mandatory, optionals, fn _k, v1, v2 -> Map.merge(v1, v2) end) + expected_device_id = device_id + expected_request = create_expected_request(request) + post_and_assert(conn, device_id, expected_device_id, request, expected_request) + end + end + + property "APIv3 notification with dropped one mandatory field", %{conn: conn} do + check all( + mandatory <- RequestsGenerator.mandatory_fields(), + optionals <- RequestsGenerator.optional_fields(), + device_id <- RequestsGenerator.device_id(), + dropped <- RequestsGenerator.mandatory_field() + ) do + request = + Map.merge(mandatory, optionals, fn _k, v1, v2 -> Map.merge(v1, v2) end) + |> drop_field(dropped) + + conn = post(conn, "/v3/notification/#{device_id}", Jason.encode!(request)) + assert json_response(conn, 422) == ControllersHelper.missing_field_response(:v3, dropped) + end + end + defp post_and_assert(conn, device_id, expected_device_id, request, expected_request) do expect(MongoosePush.Notification.MockImpl, :push, fn device_id, request -> assert request == expected_request @@ -838,4 +869,51 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do assert json_response(conn, number) == %{"reason" => to_string(error_reason)} end + + defp create_expected_request(request) do + %{ + alert: fetch_alert(request["alert"]), + data: request["data"], + mode: fetch_enum_field(request["mode"]), + service: fetch_enum_field(request["service"]), + priority: fetch_enum_field(request["priority"]), + mutable_content: fetch_mutable_content(request["mutable_content"]), + tags: request["tags"], + topic: request["topic"], + time_to_live: request["time_to_live"] + } + |> drop_nil_values() + end + + defp fetch_alert(alert) do + %{ + body: alert["body"], + title: alert["title"], + badge: alert["badge"], + click_action: alert["click_action"], + tag: alert["tag"], + sound: alert["sound"] + } + |> drop_nil_values() + end + + defp fetch_enum_field(nil), do: nil + defp fetch_enum_field(string), do: String.to_existing_atom(string) + + defp fetch_mutable_content(nil), do: false + defp fetch_mutable_content(val), do: val + + defp drop_nil_values(map) do + map + |> Enum.filter(fn {_, v} -> v != nil end) + |> Map.new() + end + + defp drop_field(map, field) when field in ["body", "title"] do + Kernel.update_in(map, ["alert"], fn _ -> Map.drop(map["alert"], [field]) end) + end + + defp drop_field(map, field) do + Map.drop(map, [field]) + end end diff --git a/test/support/controllers_helper.ex b/test/support/controllers_helper.ex index efe8a2c6..28f28134 100644 --- a/test/support/controllers_helper.ex +++ b/test/support/controllers_helper.ex @@ -66,7 +66,19 @@ defmodule MongoosePushWeb.Support.ControllersHelper do } end - def missing_field_response(field) do + def missing_field_response(api, field) when api in [:v2, :v3] and field in ["body", "title"] do + %{ + "errors" => [ + %{ + "message" => "Missing field: #{field}", + "source" => %{"pointer" => "/alert/#{field}"}, + "title" => "Invalid value" + } + ] + } + end + + def missing_field_response(_api, field) do %{ "errors" => [ %{ diff --git a/test/support/requests_generator.ex b/test/support/requests_generator.ex new file mode 100644 index 00000000..d16c3254 --- /dev/null +++ b/test/support/requests_generator.ex @@ -0,0 +1,77 @@ +defmodule MongoosePushWeb.Support.RequestsGenerator do + # APIv2/3 requests + def mandatory_fields() do + StreamData.fixed_map(%{ + "alert" => + StreamData.fixed_map(%{ + "title" => nonempty_string(), + "body" => nonempty_string() + }), + "service" => service() + }) + end + + def optional_fields() do + StreamData.optional_map(%{ + "alert" => + StreamData.optional_map(%{ + "badge" => positive_integer(), + "click_action" => nonempty_string(), + "tag" => nonempty_string(), + "sound" => nonempty_string() + }), + "data" => data(), + "mode" => mode(), + "priority" => priority(), + "mutable_content" => boolean(), + "tags" => tags(), + "topic" => nonempty_string(), + "time_to_live" => positive_integer() + }) + end + + def device_id() do + nonempty_string() + end + + def mandatory_field() do + one_of_strings([:service, :title, :body]) + end + + # basic types + defp nonempty_string() do + StreamData.string(:alphanumeric, min_length: 1) + end + + defp positive_integer() do + StreamData.positive_integer() + end + + defp boolean() do + StreamData.one_of([false, true]) + end + + defp tags() do + StreamData.list_of(nonempty_string(), min_length: 1, max_length: 5) + end + + defp priority() do + one_of_strings([:normal, :high]) + end + + defp service() do + one_of_strings([:apns, :fcm]) + end + + defp mode() do + one_of_strings([:prod, :dev]) + end + + defp data() do + StreamData.map_of(StreamData.string(:ascii), StreamData.string(:ascii)) + end + + defp one_of_strings(list_of_atoms) do + StreamData.map(StreamData.one_of(list_of_atoms), &Kernel.to_string/1) + end +end