fix(security,test): viewer-aware profile queries + create_contact ordering + valkey test isolation

- /u/:callsign profile no longer leaks private contacts or pending beacons;
  Radio.list_contacts_for_user/2 and Beacons.list_beacons_for_user/2 now
  filter by viewer (owner/admin see everything, others see only public).
- Radio.create_contact/2 places user_id on the struct before
  submission_changeset so validate_user_or_email/1 accepts authenticated
  submissions that omit submitter_email.
- valkey_test.exs runs async: false; its setup mutates global Application
  env and Process registry, which otherwise crashed concurrent ConnCase
  GridCache.clear/0 calls (Mox.UnexpectedCallError on SCAN).
This commit is contained in:
Graham McIntire 2026-05-12 08:49:08 -05:00
parent a3b97f9e98
commit 0704253af8
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 303 additions and 104 deletions

165
bugs.md
View file

@ -1,89 +1,118 @@
# Bugs Found 2026-05-11 # Bugs Found 2026-05-12
The first batch (7 items: pending-beacon access control, per_page contract, All three fixed; `mix test --seed 949374` now passes (3893 tests, 0 failures).
rate-limiter ETS lifecycle, unbounded grid endpoints, NEXRAD PNG quadratic
unfilter, LiveTableFooter parser, ScoresFile parser) was fixed and removed
from this file as each was resolved. The items below are additional bugs
surfaced by a follow-up audit.
## A. `String.to_integer` on user-controlled `band` param crashes LiveViews ## 1. Public profile leaks private contacts and pending beacons [FIXED]
**Status:** Fixed.
**Severity:** High **Severity:** High
**Category:** Availability / unhandled crash on user input **Category:** Privacy / authorization bypass
**Files:** **Files:**
- `lib/microwaveprop_web/live/path_live.ex` (formerly lines 227, 284) - `lib/microwaveprop_web/live/user_profile_live.ex:21`
- `lib/microwaveprop_web/live/map_live.ex` (formerly line 198) - `lib/microwaveprop_web/live/user_profile_live.ex:22`
- `lib/microwaveprop_web/live/user_profile_live.ex:92`
- `lib/microwaveprop_web/live/user_profile_live.ex:130`
- `lib/microwaveprop/beacons.ex:61`
`PathLive.handle_event("calculate", …)` and the auto-calculate path both `/u/:callsign` is mounted in the public LiveView session, but it loads
called `String.to_integer(params["band"])` directly on the `band` form `Radio.list_contacts_for_user(user)` and `Beacons.list_beacons_for_user(user)`
parameter. `MapLive.handle_event("select_band", %{"value" => band}, …)` without checking whether the visitor is the profile owner or an admin.
did the same. A non-numeric `band` value — supplied via a hand-crafted
URL (`/path?band=abc`) or a misbehaving JS hook — raised `ArgumentError`
inside the LiveView process and crashed the channel.
**Fix:** Use the existing `LiveHelpers.parse_int/2` (PathLive) and the `list_contacts_for_user/1` returns every submitted contact for that user,
existing `parse_band_param/1` plus a small `normalize_band_event/1` helper including contacts where `private == true`. The profile template then renders
(MapLive). Unknown bands now fall back to a safe default or a no-op those rows and links to `/contacts/:id`. The contact detail page correctly
instead of crashing the process. 404s for unauthorized viewers, but the public profile has already exposed the
contact timestamp, stations, band, mode, and distance.
## B. Mass assignment on `Radio.apply_owner_edit/3` `list_beacons_for_user/1` explicitly returns approved and pending beacons for
the public profile page. The profile template renders pending beacon callsign,
frequency, grid, keying, status, and link. This contradicts the beacon visibility
model where pending beacons should only be visible to the submitter and admins.
**Status:** Fixed. Suggested fix: split the profile query by viewer. Anonymous/non-owner visitors
**Severity:** High should see only non-private contacts and approved beacons. Owners/admins can see
**Category:** OWASP A04 Insecure Design / mass assignment their private contacts and pending beacons. Add LiveView tests for anonymous,
owner, and admin views.
## 2. `Radio.create_contact/2` rejects authenticated submissions unless callers also pass an email [FIXED]
**Severity:** Medium
**Category:** Broken context contract / validation ordering
**Files:** **Files:**
- `lib/microwaveprop/radio.ex` - `lib/microwaveprop/radio.ex:629`
- `lib/microwaveprop_web/live/contact_live/show.ex` - `lib/microwaveprop/radio.ex:632`
- `lib/microwaveprop/radio.ex:633`
- `lib/microwaveprop/radio/contact.ex:109`
- `lib/microwaveprop/radio/contact.ex:173`
`apply_owner_edit` and `apply_admin_edit` accept the LiveView form's `Radio.create_contact(attrs, user_id)` adds `user_id` after
`params` map, run it through `normalize_proposed/1`, then `diff_against_contact/2`, `Contact.submission_changeset/2` has already run `validate_user_or_email/1`.
then `apply_edit_to_contact/2`. The final stage calls That validation only sees fields cast from `attrs`, so a valid authenticated
`String.to_existing_atom(key)` for every key in the diff and pipes the create with `user_id` but no `submitter_email` returns an invalid changeset with
resulting `{atom, value}` pairs into `Ecto.Changeset.change(contact, …)`. `submitter_email: can't be blank`.
`normalize_proposed/1` did not whitelist keys, so an owner could craft Confirmed with:
a form POST with arbitrary fields — `user_id`, `flagged_invalid`,
`inserted_at`, etc. — and have them persisted onto their own contact.
**Fix:** Tighten `Radio.normalize_proposed/1` to `Map.take/2` only the ```text
known editable keys (`station1/2`, `grid1/2`, `band`, `mode`, Radio.create_contact(valid_attrs_without_submitter_email, user.id)
`qso_timestamp`, `height1/2_ft`, `private`) before any normalization / #=> {:error, %Ecto.Changeset{errors: [submitter_email: {"can't be blank", []}]}}
diffing. All three call sites (owner edit, admin edit, pending edit) ```
share this boundary, so no other change was needed. Regression test
in `contact_edit_test.exs`.
## C. `Accounts.revoke_api_token/2` raised `Ecto.Query.CastError` on a malformed UUID Current web/API call sites mask this by inserting the user's email into params
before calling the context, but the public function's contract and implementation
disagree. Any future trusted caller that relies on the `user_id` argument alone
will fail unexpectedly.
**Status:** Fixed. Suggested fix: put `user_id` into the struct or attrs before calling
**Severity:** Low `Contact.submission_changeset/2`, or move the ownership validation to after
**Category:** Availability / API contract `maybe_put_user_id/2`. Add a context-level regression test that passes
`user_id` without `submitter_email`.
`Repo.get_by(UserApiToken, id: token_id, …)` raises `Ecto.Query.CastError` ## 3. Full test suite is order-dependent around Valkey-backed `GridCache.clear/0` [FIXED]
when `token_id` does not parse as a UUID. Phoenix.Ecto's
`Plug.Exception` impl converts this to a 400 in the browser pipeline,
but the API path (`DELETE /api/v1/me/api-tokens/:id`) bypasses the
Plug.Exception fallback for actions that already return tagged tuples,
so the malformed-UUID path leaked a 500 with a generic error body
instead of the API's structured `:not_found` problem+json.
**Fix:** Rescue `Ecto.Query.CastError` in `revoke_api_token/2` and **Severity:** Medium
return `{:error, :not_found}` — same shape that propagates as a clean **Category:** Test isolation / flaky suite
404 through the existing API fallback. Regression test in **Files:**
`accounts_api_token_test.exs`. - `test/support/conn_case.ex:45`
- `test/microwaveprop/weather/grid_cache_valkey_test.exs:26`
- `test/microwaveprop/weather/grid_cache_valkey_test.exs:35`
- `lib/microwaveprop/weather/grid_cache.ex:477`
## D. `:erlang.binary_to_term/1` on disk-loaded propagation profiles One `mix test` run failed with 3 setup failures. `ConnCase` calls
`GridCache.clear/0` for every web test. If a Valkey cache test has registered
`Microwaveprop.Valkey.Conn` and swapped `:valkey_adapter`, `GridCache.clear/0`
takes the Valkey path and calls `Valkey.scan_match/1`. In the failed run, that
reached `Microwaveprop.Valkey.MockAdapter.command/3` without a `SCAN`
expectation, crashing unrelated web tests during setup.
**Status:** Fixed (defense-in-depth — pre-existing low risk). Failure shape:
**Severity:** Low
**Category:** OWASP A08 Software Integrity Failures
**File:** `lib/microwaveprop/propagation/profiles_file.ex`
`read_etf/1` decoded `.etf` files from `/data/profiles/...` with ```text
`:erlang.binary_to_term(binary)` (no `:safe` option). Files are written Mox.UnexpectedCallError: no expectation defined for
by the propagation pipeline, so the realistic threat is filesystem Microwaveprop.Valkey.MockAdapter.command/3
tampering — by which point the box is already compromised. Still, the args: [Microwaveprop.Valkey.Conn, ["SCAN", "0", "MATCH", "prop:wg:*", "COUNT", "500"], ...]
unsafe variant allows atom-table exhaustion DoS. ```
**Fix:** Pass `[:safe]` so unknown atoms and external function Verification:
references are rejected on decode.
```text
mix test --seed 949374
# 3880 tests, 3 failures, 6 skipped
mix precommit
# 3880 tests, 0 failures, 6 skipped
```
The subsequent passing `mix precommit` run makes this look order-dependent
rather than a deterministic failure.
Suggested fix: make Valkey test setup restore global process/env state before
other tests can observe it, or avoid global `Process.register/2` for `Conn`.
Also consider making `ConnCase` force the ETS fallback or installing a default
Valkey mock stub for `GridCache.clear/0`.
## Verification Notes
- `mix compile --warnings-as-errors` passed.
- `mix credo --strict` reported design/refactoring/style issues only; no bug-class findings were added from Credo.
- `mix test --seed 949374` failed as described in bug 3.
- `mix precommit` passed afterward, confirming the checked-in code still clears the normal project gate for this run.

View file

@ -59,16 +59,27 @@ defmodule Microwaveprop.Beacons do
end end
@doc """ @doc """
Returns every beacon (approved or pending) that the given user submitted, Returns beacons that the given user submitted, newest first. Used by
newest first. Used by the public `/u/:callsign` profile page. the `/u/:callsign` profile page. Pending (unapproved) beacons are
filtered out unless the viewer is the profile owner or an admin
the public profile must not leak the existence of pending drafts.
""" """
@spec list_beacons_for_user(User.t()) :: [Beacon.t()] @spec list_beacons_for_user(User.t(), User.t() | nil) :: [Beacon.t()]
def list_beacons_for_user(%User{id: user_id}) do def list_beacons_for_user(%User{} = owner, viewer \\ nil) do
Repo.all( Beacon
from b in Beacon, |> where([b], b.user_id == ^owner.id)
where: b.user_id == ^user_id, |> filter_unapproved_for_viewer(owner, viewer)
order_by: [desc: b.inserted_at] |> order_by([b], desc: b.inserted_at)
) |> Repo.all()
end
defp filter_unapproved_for_viewer(query, %User{id: owner_id}, %User{id: viewer_id}) when owner_id == viewer_id,
do: query
defp filter_unapproved_for_viewer(query, _owner, %User{is_admin: true}), do: query
defp filter_unapproved_for_viewer(query, _owner, _viewer) do
where(query, [b], b.approved == true)
end end
@doc "Gets a single beacon. Raises if not found." @doc "Gets a single beacon. Raises if not found."

View file

@ -119,18 +119,28 @@ defmodule Microwaveprop.Radio do
end end
@doc """ @doc """
Returns all contacts submitted by the given user, newest first. Used Returns contacts submitted by the given user, newest first. Used by
by the public `/u/:callsign` profile page; expected to return a small the `/u/:callsign` profile page. Private contacts are filtered out
list since only logged-in submissions carry a `user_id`. unless the viewer is the profile owner or an admin anonymous or
third-party viewers must not see private rows.
""" """
@spec list_contacts_for_user(User.t()) :: [Contact.t()] @spec list_contacts_for_user(User.t(), User.t() | nil) :: [Contact.t()]
def list_contacts_for_user(%User{id: user_id}) do def list_contacts_for_user(%User{} = owner, viewer \\ nil) do
Contact Contact
|> where([c], c.user_id == ^user_id) |> where([c], c.user_id == ^owner.id)
|> filter_private_for_viewer(owner, viewer)
|> order_by([c], desc: c.qso_timestamp, desc: c.id) |> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> Repo.all() |> Repo.all()
end end
defp filter_private_for_viewer(query, %User{id: owner_id}, %User{id: viewer_id}) when owner_id == viewer_id, do: query
defp filter_private_for_viewer(query, _owner, %User{is_admin: true}), do: query
defp filter_private_for_viewer(query, _owner, _viewer) do
where(query, [c], c.private == false)
end
@doc """ @doc """
Returns the 100 most recent contacts where the given callsign appears Returns the 100 most recent contacts where the given callsign appears
as either `station1` or `station2`, newest first. Matching is as either `station1` or `station2`, newest first. Matching is
@ -627,10 +637,11 @@ defmodule Microwaveprop.Radio do
@spec create_contact(map(), String.t() | nil) :: @spec create_contact(map(), String.t() | nil) ::
{:ok, Contact.t()} | {:error, Ecto.Changeset.t()} | {:error, :duplicate, Contact.t()} {:ok, Contact.t()} | {:error, Ecto.Changeset.t()} | {:error, :duplicate, Contact.t()}
def create_contact(attrs, user_id \\ nil) do def create_contact(attrs, user_id \\ nil) do
changeset = # Place user_id on the struct BEFORE running submission_changeset so
%Contact{} # validate_user_or_email/1 can see it. Otherwise an authenticated
|> Contact.submission_changeset(normalize_band_attr(attrs)) # caller that omits submitter_email gets a spurious "can't be blank"
|> maybe_put_user_id(user_id) # error even though user_id satisfies the ownership invariant.
changeset = Contact.submission_changeset(%Contact{user_id: user_id}, normalize_band_attr(attrs))
if changeset.valid? do if changeset.valid? do
insert_validated_contact(changeset) insert_validated_contact(changeset)
@ -664,9 +675,6 @@ defmodule Microwaveprop.Radio do
end end
end end
defp maybe_put_user_id(changeset, nil), do: changeset
defp maybe_put_user_id(changeset, user_id), do: Ecto.Changeset.put_change(changeset, :user_id, user_id)
defp insert_validated_contact(changeset) do defp insert_validated_contact(changeset) do
if existing = find_duplicate_contact(changeset) do if existing = find_duplicate_contact(changeset) do
{:error, :duplicate, existing} {:error, :duplicate, existing}

View file

@ -41,7 +41,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
def contacts(conn, _params) do def contacts(conn, _params) do
user = conn.assigns.current_api_user user = conn.assigns.current_api_user
contacts = Radio.list_contacts_for_user(user) contacts = Radio.list_contacts_for_user(user, user)
json(conn, ContactJSON.index(%{contacts: contacts, viewer: user})) json(conn, ContactJSON.index(%{contacts: contacts, viewer: user}))
end end
@ -49,7 +49,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
def beacons(conn, _params) do def beacons(conn, _params) do
user = conn.assigns.current_api_user user = conn.assigns.current_api_user
beacons = Beacons.list_beacons_for_user(user) beacons = Beacons.list_beacons_for_user(user, user)
json(conn, BeaconJSON.index(%{beacons: beacons})) json(conn, BeaconJSON.index(%{beacons: beacons}))
end end

View file

@ -20,7 +20,7 @@ defmodule MicrowavepropWeb.Api.V1.ProfileController do
user -> user ->
contacts = Radio.list_contacts_involving_callsign(user.callsign) contacts = Radio.list_contacts_involving_callsign(user.callsign)
beacons = user |> Beacons.list_beacons_for_user() |> Enum.filter(& &1.approved) beacons = Beacons.list_beacons_for_user(user)
json(conn, %{ json(conn, %{
user: UserJSON.public(user).data, user: UserJSON.public(user).data,

View file

@ -18,8 +18,9 @@ defmodule MicrowavepropWeb.UserProfileLive do
|> push_navigate(to: ~p"/")} |> push_navigate(to: ~p"/")}
user -> user ->
contacts = Radio.list_contacts_for_user(user) viewer = socket.assigns[:current_scope] && socket.assigns.current_scope.user
beacons = Beacons.list_beacons_for_user(user) contacts = Radio.list_contacts_for_user(user, viewer)
beacons = Beacons.list_beacons_for_user(user, viewer)
involving = Radio.list_contacts_involving_callsign(user.callsign) involving = Radio.list_contacts_involving_callsign(user.callsign)
{:ok, {:ok,

View file

@ -132,8 +132,8 @@ defmodule Microwaveprop.BeaconsTest do
end end
end end
describe "list_beacons_for_user/1" do describe "list_beacons_for_user/2" do
test "returns both approved and pending beacons owned by the user" do test "returns both approved and pending beacons when the viewer is the owner" do
user = user_fixture() user = user_fixture()
other = user_fixture() other = user_fixture()
@ -143,10 +143,37 @@ defmodule Microwaveprop.BeaconsTest do
pending = beacon_fixture(user, callsign: "W5PND") pending = beacon_fixture(user, callsign: "W5PND")
_theirs = beacon_fixture(other, callsign: "W5THR") _theirs = beacon_fixture(other, callsign: "W5THR")
ids = user |> Beacons.list_beacons_for_user() |> Enum.map(& &1.id) |> Enum.sort() ids = user |> Beacons.list_beacons_for_user(user) |> Enum.map(& &1.id) |> Enum.sort()
assert ids == Enum.sort([approved.id, pending.id]) assert ids == Enum.sort([approved.id, pending.id])
end end
test "hides pending beacons from anonymous viewers" do
user = user_fixture()
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
{:ok, approved} = Beacons.approve_beacon(approved)
_pending = beacon_fixture(user, callsign: "W5PND")
assert [found] = Beacons.list_beacons_for_user(user)
assert found.id == approved.id
end
test "hides pending beacons from other authenticated viewers" do
user = user_fixture()
other = user_fixture()
_pending = beacon_fixture(user, callsign: "W5PND")
assert Beacons.list_beacons_for_user(user, other) == []
end
test "shows pending beacons when the viewer is an admin" do
user = user_fixture()
admin = %{user_fixture() | is_admin: true}
pending = beacon_fixture(user, callsign: "W5PND")
assert [found] = Beacons.list_beacons_for_user(user, admin)
assert found.id == pending.id
end
test "returns an empty list when the user owns nothing" do test "returns an empty list when the user owns nothing" do
user = user_fixture() user = user_fixture()
assert Beacons.list_beacons_for_user(user) == [] assert Beacons.list_beacons_for_user(user) == []

View file

@ -512,6 +512,19 @@ defmodule Microwaveprop.RadioTest do
assert {:error, changeset} = Radio.create_contact(attrs) assert {:error, changeset} = Radio.create_contact(attrs)
assert errors_on(changeset).grid1 assert errors_on(changeset).grid1
end end
test "accepts authenticated submission without submitter_email" do
# Regression: validate_user_or_email must see user_id, not just attrs.
# Previously user_id was applied AFTER submission_changeset, so this
# call returned `{:error, %Ecto.Changeset{errors: [submitter_email: ...]}}`
# even though the user_id satisfies the ownership invariant.
user = user_fixture()
attrs = Map.delete(@valid_submission, :submitter_email)
assert {:ok, contact} = Radio.create_contact(attrs, user.id)
assert contact.user_id == user.id
assert is_nil(contact.submitter_email)
end
end end
describe "list_contacts_for_user/1" do describe "list_contacts_for_user/1" do
@ -557,11 +570,38 @@ defmodule Microwaveprop.RadioTest do
assert Radio.list_contacts_for_user(user) == [] assert Radio.list_contacts_for_user(user) == []
end end
test "includes private contacts owned by the user" do test "includes private contacts when the viewer is the owner" do
user = user_fixture() user = user_fixture()
private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true}) private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
assert [found] = Radio.list_contacts_for_user(user, user)
assert found.id == private.id
end
test "hides private contacts when the viewer is anonymous" do
user = user_fixture()
_private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
public = create_contact(%{station1: "W5PUB", user_id: user.id, private: false})
assert [found] = Radio.list_contacts_for_user(user) assert [found] = Radio.list_contacts_for_user(user)
assert found.id == public.id
end
test "hides private contacts from other authenticated viewers" do
user = user_fixture()
other = user_fixture()
_private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
assert Radio.list_contacts_for_user(user, other) == []
end
test "includes private contacts when the viewer is an admin" do
user = user_fixture()
admin = user_fixture()
admin = %{admin | is_admin: true}
private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
assert [found] = Radio.list_contacts_for_user(user, admin)
assert found.id == private.id assert found.id == private.id
end end
end end

View file

@ -1,5 +1,10 @@
defmodule Microwaveprop.ValkeyTest do defmodule Microwaveprop.ValkeyTest do
use ExUnit.Case, async: true # Setup registers a process under the global `Microwaveprop.Valkey.Conn`
# name and swaps `:microwaveprop, :valkey_adapter` via Application.put_env.
# Both are global state, so this case must not run concurrently with other
# tests — otherwise ConnCase setups (which call GridCache.clear/0) see the
# Mox adapter without expectations and crash unrelated tests.
use ExUnit.Case, async: false
import Mox import Mox

View file

@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
import Microwaveprop.BeaconsFixtures import Microwaveprop.BeaconsFixtures
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.Accounts.User
alias Microwaveprop.Beacons alias Microwaveprop.Beacons
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo alias Microwaveprop.Repo
@ -80,7 +81,21 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
assert {:error, {:live_redirect, %{to: "/"}}} = live(conn, ~p"/u/NO1SUCH") assert {:error, {:live_redirect, %{to: "/"}}} = live(conn, ~p"/u/NO1SUCH")
end end
test "lists both approved and pending beacons so owners see their drafts", %{conn: conn} do test "owners see both approved and pending beacons on their own profile", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
{:ok, _approved} = Beacons.approve_beacon(approved)
_pending = beacon_fixture(user, callsign: "W5PND")
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "W5PND"
assert html =~ "Pending"
assert html =~ "Approved"
end
test "anonymous visitors do not see pending beacons", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"}) user = user_fixture(%{callsign: "W5ISP"})
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs()) {:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
{:ok, _approved} = Beacons.approve_beacon(approved) {:ok, _approved} = Beacons.approve_beacon(approved)
@ -88,9 +103,72 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}") {:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
refute html =~ "W5PND"
refute html =~ "Pending"
assert html =~ "Approved"
end
test "another logged-in user does not see pending beacons", %{conn: conn} do
owner = user_fixture(%{callsign: "W5ISP"})
other = user_fixture(%{callsign: "W5OTH"})
_pending = beacon_fixture(owner, callsign: "W5PND")
conn = log_in_user(conn, other)
{:ok, _lv, html} = live(conn, ~p"/u/#{owner.callsign}")
refute html =~ "W5PND"
refute html =~ "Pending"
end
test "admins see pending beacons on any profile", %{conn: conn} do
owner = user_fixture(%{callsign: "W5ISP"})
admin = user_fixture(%{callsign: "W5ADM"})
{1, _} =
Repo.update_all(
from(u in User, where: u.id == ^admin.id),
set: [is_admin: true]
)
_pending = beacon_fixture(owner, callsign: "W5PND")
conn = log_in_user(conn, %{admin | is_admin: true})
{:ok, _lv, html} = live(conn, ~p"/u/#{owner.callsign}")
assert html =~ "W5PND" assert html =~ "W5PND"
assert html =~ "Pending" assert html =~ "Pending"
assert html =~ "Approved" end
test "anonymous visitors do not see private contacts", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
_public = contact_for(user, %{station1: "W5PUB"})
_hidden = contact_for(user, %{station1: "W5SECRET", private: true})
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "W5PUB"
refute html =~ "W5SECRET"
end
test "owners see their own private contacts", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
_hidden = contact_for(user, %{station1: "W5SECRET", private: true})
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "W5SECRET"
end
test "another logged-in user does not see private contacts", %{conn: conn} do
owner = user_fixture(%{callsign: "W5ISP"})
other = user_fixture(%{callsign: "W5OTH"})
_hidden = contact_for(owner, %{station1: "W5SECRET", private: true})
conn = log_in_user(conn, other)
{:ok, _lv, html} = live(conn, ~p"/u/#{owner.callsign}")
refute html =~ "W5SECRET"
end end
test "renders the 'Contacts involving' section with contacts that mention the callsign as a remote station", test "renders the 'Contacts involving' section with contacts that mention the callsign as a remote station",
@ -128,7 +206,7 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
{1, _} = {1, _} =
Repo.update_all( Repo.update_all(
from(u in Microwaveprop.Accounts.User, where: u.id == ^user.id), from(u in User, where: u.id == ^user.id),
set: [name: ""] set: [name: ""]
) )