- Radio.normalize_proposed/1 now Map.take/2 the allowed-edit keys
before normalization. Owner / admin / pending-edit submit paths all
funnel through this boundary, so a crafted form can no longer
mass-assign user_id, flagged_invalid, inserted_at, etc. on a
contact via String.to_existing_atom -> Ecto.Changeset.change.
- Accounts.revoke_api_token/2 rescues Ecto.Query.CastError and
returns {:error, :not_found} so a malformed UUID in
DELETE /api/v1/me/api-tokens/:id renders the API's clean 404
problem+json instead of a 500.
- ProfilesFile.read_etf decodes with :erlang.binary_to_term(bin, [:safe]).
Defense-in-depth against tampered on-disk profiles (atom-table
exhaustion via untrusted ETF).
- Regression tests: contact_edit_test.exs covers the rejected
mass-assignment fields; accounts_api_token_test.exs covers the
malformed-UUID path.
118 lines
4.2 KiB
Elixir
118 lines
4.2 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
|
|
|
|
test "returns :not_found for a malformed UUID", %{user: user} do
|
|
assert {:error, :not_found} = Accounts.revoke_api_token(user, "not-a-uuid")
|
|
end
|
|
end
|
|
end
|