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).
134 lines
3.8 KiB
Elixir
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
|