Adds bearer-token authenticated REST API at /api/v1 covering every action a non-admin user can perform on the website: contact + beacon submission, beacon-monitor management, propagation queries, profile read/update, and self-service API token issuance/revocation. Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown once at creation), RFC 9457 problem+json error responses, RFC 9651 RateLimit-* headers backed by an ETS bucket (600/min per token, 60/min per anonymous IP, 30/min on /auth/tokens), private-contact filtering by viewer. Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml (OpenAPI 3.1 spec covering every endpoint, response, and schema). Tests: 124 new tests across schema, plug, error renderer, rate limiter, fallback, and every controller. 16/17 API modules at 100% line coverage; FallbackController at 87.5% (one defmodule line, an Erlang-cover artifact for action_fallback-only modules).
103 lines
3 KiB
Elixir
103 lines
3 KiB
Elixir
defmodule MicrowavepropWeb.Api.ErrorJSONTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
import Plug.Conn
|
|
import Plug.Test
|
|
|
|
alias Ecto.Changeset
|
|
alias MicrowavepropWeb.Api.ErrorJSON
|
|
|
|
defmodule Dummy do
|
|
@moduledoc false
|
|
use Ecto.Schema
|
|
|
|
import Changeset
|
|
|
|
embedded_schema do
|
|
field :name, :string
|
|
field :age, :integer
|
|
end
|
|
|
|
def cs(attrs) do
|
|
%__MODULE__{}
|
|
|> cast(attrs, [:name, :age])
|
|
|> validate_required([:name])
|
|
|> validate_number(:age, greater_than: 0)
|
|
end
|
|
end
|
|
|
|
describe "send_problem/4" do
|
|
test "sets content-type, status, halts, and emits a problem+json body" do
|
|
conn = conn(:get, "/")
|
|
|
|
conn = ErrorJSON.send_problem(conn, 401, "unauthorized", "Token missing.")
|
|
|
|
assert conn.halted
|
|
assert conn.status == 401
|
|
assert get_resp_header(conn, "content-type") == ["application/problem+json; charset=utf-8"]
|
|
|
|
assert %{
|
|
"type" => "about:blank",
|
|
"title" => "unauthorized",
|
|
"status" => 401,
|
|
"detail" => "Token missing."
|
|
} = Jason.decode!(conn.resp_body)
|
|
end
|
|
|
|
test "merges extra fields into the body" do
|
|
conn = ErrorJSON.send_problem(conn(:get, "/"), 422, "x", "y", %{errors: %{a: ["b"]}})
|
|
|
|
assert %{"errors" => %{"a" => ["b"]}} = Jason.decode!(conn.resp_body)
|
|
end
|
|
end
|
|
|
|
describe "send_changeset/2" do
|
|
test "renders flat field error map at status 422" do
|
|
cs = Dummy.cs(%{age: -1})
|
|
|
|
conn = ErrorJSON.send_changeset(conn(:get, "/"), cs)
|
|
|
|
assert conn.status == 422
|
|
body = Jason.decode!(conn.resp_body)
|
|
assert body["title"] == "validation_failed"
|
|
assert %{"name" => ["can't be blank"], "age" => ["must be greater than 0"]} = body["errors"]
|
|
end
|
|
end
|
|
|
|
describe "translate_errors/1" do
|
|
test "interpolates option placeholders into messages" do
|
|
cs =
|
|
%Changeset{}
|
|
|> Map.put(:errors, name: {"too short %{count}", [count: 3]})
|
|
|> Map.put(:types, %{name: :string})
|
|
|> Map.put(:data, %{})
|
|
|
|
assert %{name: ["too short 3"]} = ErrorJSON.translate_errors(cs)
|
|
end
|
|
end
|
|
|
|
describe "render/2" do
|
|
for {tpl, expected_status, expected_title} <- [
|
|
{"400.json", 400, "bad_request"},
|
|
{"401.json", 401, "unauthorized"},
|
|
{"403.json", 403, "forbidden"},
|
|
{"404.json", 404, "not_found"},
|
|
{"405.json", 405, "method_not_allowed"},
|
|
{"415.json", 415, "unsupported_media_type"},
|
|
{"429.json", 429, "too_many_requests"},
|
|
{"500.json", 500, "internal_server_error"}
|
|
] do
|
|
test "produces a problem map for #{tpl}" do
|
|
body = ErrorJSON.render(unquote(tpl), %{})
|
|
assert body.status == unquote(expected_status)
|
|
assert body.title == unquote(expected_title)
|
|
end
|
|
end
|
|
|
|
test "falls back generically for unknown templates" do
|
|
body = ErrorJSON.render("418.json", %{})
|
|
assert body.status == 418
|
|
assert body.title == "im_a_teapot"
|
|
end
|
|
end
|
|
end
|