prop/test/microwaveprop_web/controllers/api/v1/contact_controller_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

162 lines
5.1 KiB
Elixir

defmodule MicrowavepropWeb.Api.V1.ContactControllerTest do
use MicrowavepropWeb.ConnCase, async: true
alias Microwaveprop.Accounts
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Radio
alias MicrowavepropWeb.Api.RateLimiter
@valid_attrs %{
"station1" => "W5XD",
"station2" => "K5XD",
"qso_timestamp" => "2026-01-01T00:00:00Z",
"band" => "10000",
"grid1" => "EM12",
"grid2" => "EM13",
"mode" => "CW",
"submitter_email" => "fixture@example.com"
}
setup %{conn: conn} do
RateLimiter.reset()
user = AccountsFixtures.user_fixture()
{:ok, {plaintext, _r}} = Accounts.create_api_token(user, %{name: "t"})
%{conn: conn, user: user, plaintext: plaintext}
end
describe "GET /api/v1/contacts" do
test "returns paginated public contacts", %{conn: conn, user: user} do
{:ok, _} = Radio.create_contact(@valid_attrs, user.id)
body = conn |> get(~p"/api/v1/contacts") |> json_response(200)
assert is_list(body["data"])
assert body["meta"]["page"] == 1
assert body["meta"]["per_page"] == 50
assert body["meta"]["total_entries"] >= 1
end
test "honors page and per_page", %{conn: conn} do
conn = get(conn, ~p"/api/v1/contacts?page=2&per_page=10")
body = json_response(conn, 200)
assert body["meta"]["page"] == 2
assert body["meta"]["per_page"] == 10
end
test "clamps insane per_page values", %{conn: conn} do
conn = get(conn, ~p"/api/v1/contacts?per_page=99999")
body = json_response(conn, 200)
assert body["meta"]["per_page"] == 200
end
test "rejects invalid pagination values silently with defaults", %{conn: conn} do
conn = get(conn, ~p"/api/v1/contacts?page=abc&per_page=zero")
body = json_response(conn, 200)
assert body["meta"]["page"] == 1
assert body["meta"]["per_page"] == 50
end
test "supports search", %{conn: conn, user: user} do
{:ok, _} = Radio.create_contact(@valid_attrs, user.id)
body = conn |> get(~p"/api/v1/contacts?search=W5XD") |> json_response(200)
assert body["meta"]["total_entries"] >= 1
end
end
describe "GET /api/v1/contacts/:id" do
test "returns the contact for a known id", %{conn: conn, user: user} do
{:ok, contact} = Radio.create_contact(@valid_attrs, user.id)
body = conn |> get(~p"/api/v1/contacts/#{contact.id}") |> json_response(200)
assert body["data"]["id"] == contact.id
end
test "404 for unknown id", %{conn: conn} do
conn = get(conn, ~p"/api/v1/contacts/00000000-0000-0000-0000-000000000000")
assert json_response(conn, 404)
end
test "404 hides private contacts from anonymous viewers", %{conn: conn, user: user} do
{:ok, contact} =
@valid_attrs
|> Map.put("private", true)
|> Radio.create_contact(user.id)
conn = get(conn, ~p"/api/v1/contacts/#{contact.id}")
assert json_response(conn, 404)
end
test "owner can fetch their own private contact", %{conn: conn, user: user, plaintext: pt} do
{:ok, contact} =
@valid_attrs
|> Map.put("private", true)
|> Radio.create_contact(user.id)
body =
conn
|> put_req_header("authorization", "Bearer " <> pt)
|> get(~p"/api/v1/contacts/#{contact.id}")
|> json_response(200)
assert body["data"]["id"] == contact.id
assert body["data"]["private"] == true
end
test "non-owner gets 404 on someone else's private contact", %{conn: conn, user: owner} do
other = AccountsFixtures.user_fixture()
{:ok, {pt, _}} = Accounts.create_api_token(other, %{name: "x"})
{:ok, contact} =
@valid_attrs
|> Map.put("private", true)
|> Radio.create_contact(owner.id)
conn =
conn
|> put_req_header("authorization", "Bearer " <> pt)
|> get(~p"/api/v1/contacts/#{contact.id}")
assert json_response(conn, 404)
end
end
describe "POST /api/v1/contacts" do
test "requires auth", %{conn: conn} do
conn = post(conn, ~p"/api/v1/contacts", @valid_attrs)
assert json_response(conn, 401)
end
test "creates a contact for the authenticated user", %{conn: conn, plaintext: pt} do
conn =
conn
|> put_req_header("authorization", "Bearer " <> pt)
|> post(~p"/api/v1/contacts", @valid_attrs)
body = json_response(conn, 201)
assert body["data"]["station1"] == "W5XD"
assert body["data"]["mine?"] == true
end
test "returns 422 on validation errors", %{conn: conn, plaintext: pt} do
conn =
conn
|> put_req_header("authorization", "Bearer " <> pt)
|> post(~p"/api/v1/contacts", %{"station1" => "X"})
assert json_response(conn, 422)
end
test "returns 409 on duplicate submission", %{conn: conn, plaintext: pt, user: user} do
{:ok, _} = Radio.create_contact(@valid_attrs, user.id)
conn =
conn
|> put_req_header("authorization", "Bearer " <> pt)
|> post(~p"/api/v1/contacts", @valid_attrs)
body = json_response(conn, 409)
assert body["title"] == "duplicate_contact"
assert body["existing"]
end
end
end