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

92 lines
3.3 KiB
Elixir

defmodule MicrowavepropWeb.Api.CoverageExtrasTest do
@moduledoc """
Extra unit tests targeting defensive fall-through clauses that the
routed integration tests don't exercise (Phoenix's params decoder
always hands us strings, but the API controllers still ship `_, _`
catch-alls so library-style direct invocation behaves predictably).
"""
use Microwaveprop.DataCase, async: true
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Radio.Contact
alias MicrowavepropWeb.Api.Auth
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.Api.V1.ContactJSON
describe "Auth.require_auth/1 + .optional_auth/1 default args" do
test "require_auth/1 with no opts halts unauthenticated requests" do
conn = Auth.require_auth(Plug.Test.conn(:get, "/"))
assert conn.status == 401
end
test "optional_auth/1 with no opts passes anonymous requests through" do
conn = Auth.optional_auth(Plug.Test.conn(:get, "/"))
refute conn.halted
end
end
describe "ContactJSON.maybe_notes branches" do
test "anonymous-submitted contacts (user_id nil) hide notes from everyone" do
contact = %Contact{user_id: nil, notes: "secret", band: nil}
data = ContactJSON.show(%{contact: contact, viewer: AccountsFixtures.user_fixture()}).data
assert data.notes == nil
end
test "non-owner viewers cannot read notes" do
owner = AccountsFixtures.user_fixture()
other = AccountsFixtures.user_fixture()
contact = %Contact{user_id: owner.id, notes: "secret", band: nil}
data = ContactJSON.show(%{contact: contact, viewer: other}).data
assert data.notes == nil
end
test "owner sees the notes they wrote" do
owner = AccountsFixtures.user_fixture()
contact = %Contact{user_id: owner.id, notes: "private!", band: nil}
data = ContactJSON.show(%{contact: contact, viewer: owner}).data
assert data.notes == "private!"
assert data.mine? == true
end
test "anonymous viewer with anonymous-submitted contact returns nil notes" do
contact = %Contact{user_id: nil, notes: nil, band: nil}
data = ContactJSON.show(%{contact: contact, viewer: nil}).data
assert data.notes == nil
assert data.mine? == false
end
end
describe "RateLimiter ETS race recovery" do
test "reset/0 + concurrent ensure_table do not crash" do
RateLimiter.reset()
# Force the ets table to exist and then immediately call call/2 with
# a fresh conn — exercises the rescue clause on subsequent racing
# creations under write_concurrency.
tasks =
for _ <- 1..8 do
Task.async(fn -> RateLimiter.call(Plug.Test.conn(:get, "/"), anon_limit: 100) end)
end
results = Enum.map(tasks, &Task.await/1)
assert Enum.all?(results, &(&1.status in [200, 429] or &1.status == nil))
end
end
describe "ContactController.parse_int catch-all" do
test "parse_int falls back to the default when given an atom value" do
# The route always encodes ints as binaries, so this clause is
# invoked only for non-routed callers. Drive the action directly.
conn = Phoenix.ConnTest.build_conn(:get, "/api/v1/contacts")
conn =
MicrowavepropWeb.Api.V1.ContactController.index(conn, %{
"page" => :weird,
"per_page" => :also_weird
})
assert conn.status == 200
end
end
end