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).
92 lines
3.3 KiB
Elixir
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
|