prop/test/microwaveprop/accounts_api_token_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

114 lines
4 KiB
Elixir

defmodule Microwaveprop.AccountsApiTokenTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.UserApiToken
alias Microwaveprop.AccountsFixtures
setup do
%{user: AccountsFixtures.user_fixture()}
end
describe "create_api_token/2" do
test "returns plaintext + persisted record", %{user: user} do
assert {:ok, {plaintext, %UserApiToken{} = record}} =
Accounts.create_api_token(user, %{name: "Laptop"})
assert String.starts_with?(plaintext, "mwp_")
assert record.user_id == user.id
assert record.token_hash == UserApiToken.hash_token(plaintext)
assert record.id
end
test "surfaces validation errors", %{user: user} do
assert {:error, %Ecto.Changeset{}} =
Accounts.create_api_token(user, %{name: ""})
end
test "rejects expires_at in the past", %{user: user} do
past = DateTime.add(DateTime.utc_now(), -1, :second)
assert {:error, changeset} =
Accounts.create_api_token(user, %{name: "x", expires_at: past})
assert %{expires_at: ["must be in the future"]} = errors_on(changeset)
end
end
describe "list_api_tokens/1" do
test "returns only non-revoked tokens for the user, newest first", %{user: user} do
other = AccountsFixtures.user_fixture()
{:ok, {_, t1}} = Accounts.create_api_token(user, %{name: "A"})
{:ok, {_, t2}} = Accounts.create_api_token(user, %{name: "B"})
{:ok, {_, _t3}} = Accounts.create_api_token(other, %{name: "Other"})
{:ok, _} = Accounts.revoke_api_token(user, t1.id)
assert [token] = Accounts.list_api_tokens(user)
assert token.id == t2.id
end
end
describe "get_user_by_api_token/1" do
test "returns user + token + updates last_used_at on valid", %{user: user} do
{:ok, {plaintext, original}} = Accounts.create_api_token(user, %{name: "A"})
assert is_nil(original.last_used_at)
assert {:ok, returned_user, returned_token} =
Accounts.get_user_by_api_token(plaintext)
assert returned_user.id == user.id
assert returned_token.id == original.id
assert %DateTime{} = returned_token.last_used_at
end
test "rejects unknown plaintext" do
assert {:error, :invalid_token} = Accounts.get_user_by_api_token("nope")
end
test "rejects revoked tokens", %{user: user} do
{:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A"})
{:ok, _} = Accounts.revoke_api_token(user, record.id)
assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext)
end
test "rejects expired tokens", %{user: user} do
future = DateTime.add(DateTime.utc_now(), 60, :second)
{:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A", expires_at: future})
# Force the row past its expiry.
Repo.update_all(
from(t in UserApiToken, where: t.id == ^record.id),
set: [expires_at: DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.truncate(:second)]
)
assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext)
end
end
describe "revoke_api_token/2" do
test "marks the token revoked", %{user: user} do
{:ok, {_pt, record}} = Accounts.create_api_token(user, %{name: "x"})
assert {:ok, revoked} = Accounts.revoke_api_token(user, record.id)
assert %DateTime{} = revoked.revoked_at
end
test "is idempotent for already-revoked tokens", %{user: user} do
{:ok, {_pt, record}} = Accounts.create_api_token(user, %{name: "x"})
{:ok, first} = Accounts.revoke_api_token(user, record.id)
{:ok, second} = Accounts.revoke_api_token(user, record.id)
assert DateTime.compare(first.revoked_at, second.revoked_at) == :eq
end
test "returns :not_found for tokens owned by other users", %{user: user} do
other = AccountsFixtures.user_fixture()
{:ok, {_pt, record}} = Accounts.create_api_token(user, %{name: "x"})
assert {:error, :not_found} = Accounts.revoke_api_token(other, record.id)
end
end
end