prop/test/microwaveprop_web/api/error_json_test.exs
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
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).
2026-05-09 08:59:54 -05:00

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