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).
162 lines
5.1 KiB
Elixir
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
|