prop/test/microwaveprop_web/api/auth_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

134 lines
3.8 KiB
Elixir

defmodule MicrowavepropWeb.Api.AuthTest do
use Microwaveprop.DataCase, async: true
import Plug.Conn
import Plug.Test
alias Microwaveprop.Accounts
alias Microwaveprop.AccountsFixtures
alias MicrowavepropWeb.Api.Auth
setup do
user = AccountsFixtures.user_fixture()
{:ok, {plaintext, token}} = Accounts.create_api_token(user, %{name: "T"})
%{user: user, plaintext: plaintext, token: token}
end
describe "init/1" do
test "defaults to require mode" do
assert Auth.init([]) == [mode: :require]
end
test "respects an explicit mode" do
assert Auth.init(mode: :optional) == [mode: :optional]
end
end
describe "call/2 in :require mode" do
test "halts with 401 problem+json when no header is present" do
conn = Auth.call(conn(:get, "/"), Auth.init([]))
assert conn.halted
assert conn.status == 401
assert get_resp_header(conn, "www-authenticate") == [~s(Bearer realm="api")]
assert %{"detail" => "Missing bearer token in Authorization header."} = Jason.decode!(conn.resp_body)
end
test "halts when the header is malformed" do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "Token abc")
|> Auth.call(Auth.init([]))
assert conn.status == 401
assert %{"detail" => "Authorization header must be `Bearer <token>`."} = Jason.decode!(conn.resp_body)
end
test "halts when the bearer scheme is present but token is empty" do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "Bearer ")
|> Auth.call(Auth.init([]))
assert conn.status == 401
end
test "halts when the token is unknown / revoked" do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "Bearer mwp_does-not-exist")
|> Auth.call(Auth.init([]))
assert conn.status == 401
assert %{"detail" => "Bearer token is invalid, expired, or revoked."} = Jason.decode!(conn.resp_body)
end
test "lets a valid token through and assigns user + token", %{user: user, plaintext: pt} do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "Bearer #{pt}")
|> Auth.call(Auth.init([]))
refute conn.halted
assert conn.assigns.current_api_user.id == user.id
assert conn.assigns.current_api_token
end
test "accepts the lowercase `bearer` scheme too", %{plaintext: pt} do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "bearer #{pt}")
|> Auth.call(Auth.init([]))
refute conn.halted
end
end
describe "call/2 in :optional mode" do
test "passes through when no token is present and assigns nil" do
conn = Auth.call(conn(:get, "/"), Auth.init(mode: :optional))
refute conn.halted
assert conn.assigns.current_api_user == nil
assert conn.assigns.current_api_token == nil
end
test "halts when an invalid token is provided" do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "Bearer mwp_bad")
|> Auth.call(Auth.init(mode: :optional))
assert conn.halted
assert conn.status == 401
end
test "halts when the header is malformed in :optional mode" do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "garbage")
|> Auth.call(Auth.init(mode: :optional))
assert conn.halted
assert conn.status == 401
end
test "lets a valid token bind user + token", %{user: user, plaintext: pt} do
conn =
:get
|> conn("/")
|> put_req_header("authorization", "Bearer #{pt}")
|> Auth.call(Auth.init(mode: :optional))
refute conn.halted
assert conn.assigns.current_api_user.id == user.id
end
end
end