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

139 lines
4.2 KiB
Elixir

defmodule MicrowavepropWeb.Api.V1.MeControllerTest do
use MicrowavepropWeb.ConnCase, async: true
alias Microwaveprop.Accounts
alias Microwaveprop.AccountsFixtures
alias MicrowavepropWeb.Api.RateLimiter
setup %{conn: conn} do
RateLimiter.reset()
user = AccountsFixtures.user_fixture()
{:ok, {plaintext, _record}} = Accounts.create_api_token(user, %{name: "test"})
authed = put_req_header(conn, "authorization", "Bearer " <> plaintext)
%{user: user, plaintext: plaintext, authed: authed}
end
describe "GET /api/v1/me" do
test "requires auth", %{conn: conn} do
conn = get(conn, ~p"/api/v1/me")
assert json_response(conn, 401)
end
test "returns the authenticated user's profile", %{authed: conn, user: user} do
conn = get(conn, ~p"/api/v1/me")
body = json_response(conn, 200)
assert body["data"]["email"] == user.email
assert body["data"]["callsign"] == user.callsign
assert is_boolean(body["data"]["is_admin"])
end
end
describe "PATCH /api/v1/me" do
test "updates the home QTH from a Maidenhead grid", %{authed: conn} do
conn = patch(conn, ~p"/api/v1/me", %{"home_grid" => "EM12kx"})
body = json_response(conn, 200)
assert body["data"]["home_grid"] == "EM12kx"
assert is_float(body["data"]["home_lat"])
end
test "returns 422 on bad QTH", %{authed: conn} do
conn = patch(conn, ~p"/api/v1/me", %{"home_grid" => "ZZ99zz"})
assert json_response(conn, 422)
end
end
describe "GET /api/v1/me/contacts" do
test "returns contacts owned by the user", %{authed: conn, user: user} do
{:ok, contact} =
Microwaveprop.Radio.create_contact(
%{
"station1" => "W5XD",
"station2" => "K5XD",
"qso_timestamp" => "2026-01-01T00:00:00Z",
"band" => "10000",
"grid1" => "EM12",
"grid2" => "EM13",
"mode" => "CW",
"submitter_email" => "fixture@example.com"
},
user.id
)
conn = get(conn, ~p"/api/v1/me/contacts")
body = json_response(conn, 200)
assert [%{"id" => id, "mine?" => true}] = body["data"]
assert id == contact.id
end
end
describe "GET /api/v1/me/beacons" do
test "returns beacons submitted by the user", %{authed: conn, user: user} do
{:ok, _b} =
Microwaveprop.Beacons.create_beacon(user, %{
frequency_mhz: 10_368.1,
callsign: "W5HN",
lat: 32.897,
lon: -97.038,
power_mw: 1000.0,
height_ft: 100,
keying: "on_off"
})
conn = get(conn, ~p"/api/v1/me/beacons")
assert %{"data" => [_one]} = json_response(conn, 200)
end
end
describe "API token CRUD" do
test "lists, then revokes, an API token", %{authed: conn} do
list = conn |> get(~p"/api/v1/me/api-tokens") |> json_response(200)
assert [%{"id" => id} | _] = list["data"]
revoke = conn |> delete(~p"/api/v1/me/api-tokens/#{id}") |> json_response(200)
assert revoke["data"]["revoked_at"]
end
test "404 when revoking unknown id", %{authed: conn} do
conn =
delete(
conn,
~p"/api/v1/me/api-tokens/00000000-0000-0000-0000-000000000000"
)
assert json_response(conn, 404)
end
end
describe "beacon monitor CRUD" do
test "creates, lists, and deletes a beacon monitor", %{authed: conn} do
created =
conn
|> post(~p"/api/v1/me/beacon-monitors", %{"name" => "Tower"})
|> json_response(201)
id = created["data"]["id"]
list = conn |> get(~p"/api/v1/me/beacon-monitors") |> json_response(200)
assert Enum.any?(list["data"], &(&1["id"] == id))
conn = delete(conn, ~p"/api/v1/me/beacon-monitors/#{id}")
assert response(conn, 204)
end
test "422 when creating with empty name", %{authed: conn} do
conn = post(conn, ~p"/api/v1/me/beacon-monitors", %{"name" => ""})
assert json_response(conn, 422)
end
test "404 when deleting unknown monitor", %{authed: conn} do
conn =
delete(
conn,
~p"/api/v1/me/beacon-monitors/00000000-0000-0000-0000-000000000000"
)
assert json_response(conn, 404)
end
end
end