prop/test/microwaveprop_web/live/pskr_spots_live_test.exs
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT

P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1

P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades

P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
2026-07-27 18:19:37 -05:00

322 lines
9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.PskrSpotsLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Repo
defp now, do: DateTime.utc_now()
defp dump_uuid, do: Ecto.UUID.dump!(Ecto.UUID.generate())
defp insert_spot(attrs) do
defaults = %{
hour_utc: now(),
band: "2m",
sender_grid: "EM12KL",
receiver_grid: "DM43ST",
spot_count: 1
}
fields = Map.merge(defaults, attrs)
Repo.query!(
"""
INSERT INTO pskr_spots_hourly
(id, hour_utc, band, sender_grid, receiver_grid,
sender_lat, sender_lon, receiver_lat, receiver_lon,
distance_km, spot_count, max_snr_db, min_snr_db,
modes, sender_callsigns, receiver_callsigns,
first_spot_at, last_spot_at,
inserted_at, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)
""",
[
dump_uuid(),
fields.hour_utc,
fields.band,
fields.sender_grid,
fields.receiver_grid,
Map.get(fields, :sender_lat, 32.5),
Map.get(fields, :sender_lon, -96.5),
Map.get(fields, :receiver_lat, 33.5),
Map.get(fields, :receiver_lon, -110.5),
Map.get(fields, :distance_km, 1_200.0),
fields.spot_count,
Map.get(fields, :max_snr_db, 5),
Map.get(fields, :min_snr_db, 0),
Map.get(fields, :modes, ["FT8"]),
Map.get(fields, :sender_callsigns, []),
Map.get(fields, :receiver_callsigns, []),
Map.get(fields, :first_spot_at, fields.hour_utc),
Map.get(fields, :last_spot_at, fields.hour_utc),
fields.hour_utc
]
)
end
describe "page rendering" do
test "renders the pskr spots page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "PSK Reporter Spots"
end
test "sets page title", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "PSK Reporter Spots"
end
test "shows empty state when no spots exist", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "No PSK Reporter spots received yet"
end
test "shows total spots count of zero when empty", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "Total spots stored:"
assert html =~ "0"
end
end
describe "with spots in the database" do
test "displays total spots count and per-band counts", %{conn: conn} do
insert_spot(%{band: "2m", spot_count: 42})
insert_spot(%{band: "70cm", spot_count: 7})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
html = render_async(lv)
assert html =~ "Total spots stored:"
# 42 + 7 = 49 total
assert html =~ "49"
# Band badges show per-band counts
assert html =~ "2m"
assert html =~ "70cm"
end
test "displays band as badge", %{conn: conn} do
insert_spot(%{band: "70cm"})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "70cm"
end
test "displays SNR range", %{conn: conn} do
insert_spot(%{min_snr_db: -5, max_snr_db: 12})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "-512 dB"
end
test "displays single SNR when min equals max", %{conn: conn} do
insert_spot(%{min_snr_db: 3, max_snr_db: 3})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "3 dB"
refute html =~ "33 dB"
end
test "displays sender and receiver callsigns", %{conn: conn} do
insert_spot(%{sender_callsigns: ["K5ABC"], receiver_callsigns: ["K7XYZ"]})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "K5ABC"
assert html =~ "K7XYZ"
end
test "displays multiple callsigns comma-separated", %{conn: conn} do
insert_spot(%{sender_callsigns: ["K5ABC", "W1AW"], receiver_callsigns: ["K7XYZ"]})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "K5ABC, W1AW"
end
test "shows dash for empty callsigns", %{conn: conn} do
insert_spot(%{sender_callsigns: [], receiver_callsigns: []})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ ""
end
test "displays modes", %{conn: conn} do
insert_spot(%{modes: ["FT8", "FT4"]})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "FT8"
assert html =~ "FT4"
end
test "shows most recent spots first", %{conn: conn} do
t1 = ~U[2026-07-01 12:00:00Z]
t2 = ~U[2026-07-01 13:00:00Z]
insert_spot(%{sender_grid: "EARLIER", last_spot_at: t1, hour_utc: t1})
insert_spot(%{sender_grid: "LATER", last_spot_at: t2, hour_utc: t2})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
# LATER should appear before EARLIER in the HTML
assert String.contains?(html, "LATER")
assert String.contains?(html, "EARLIER")
{l_pos, _} = :binary.match(html, "LATER")
{e_pos, _} = :binary.match(html, "EARLIER")
assert l_pos < e_pos
end
test "limits to 100 most recent spots", %{conn: conn} do
# Insert 105 spots — only the 100 most recent should render
# Pad single-digit grid numbers so substring matching is unambiguous
for i <- 1..105 do
t = DateTime.add(now(), i - 105, :second)
grid = if i < 10, do: "GRID00#{i}", else: "GRID#{i}"
insert_spot(%{sender_grid: grid, last_spot_at: t, hour_utc: t})
end
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
# The oldest 5 should be excluded
refute html =~ "GRID001"
refute html =~ "GRID002"
refute html =~ "GRID003"
refute html =~ "GRID004"
refute html =~ "GRID005"
# The most recent 100 should be present
assert html =~ "GRID105"
assert html =~ "GRID006"
end
end
describe "band filtering" do
test "filtering by band shows only that band's spots", %{conn: conn} do
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
# Wait for async band_counts to complete, then re-render
html = render_async(lv)
# Both bands visible in table
assert html =~ "GRID2M"
assert html =~ "GRID70"
# Click the 2m badge
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
assert html =~ "GRID2M"
refute html =~ "GRID70"
# Filter badge should be highlighted
assert html =~ "bg-primary/15"
end
test "clicking the active band again clears the filter", %{conn: conn} do
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
_html = render_async(lv)
# Filter to 2m
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
refute html =~ "GRID70"
# Click 2m again to clear
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
assert html =~ "GRID2M"
assert html =~ "GRID70"
end
test "clear filter button appears when filter is active", %{conn: conn} do
insert_spot(%{band: "2m", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
html = render_async(lv)
# No clear filter button initially
refute html =~ "Clear filter"
# Filter by band
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
assert html =~ "Clear filter"
# Click clear filter
html =
lv
|> element("button", "Clear filter")
|> render_click()
refute html =~ "Clear filter"
end
test "subtitle reflects active filter", %{conn: conn} do
insert_spot(%{band: "70cm", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
html = render_async(lv)
assert html =~ "from the MQTT firehose"
html =
lv
|> element("button[phx-value-band=\"70cm\"]")
|> render_click()
assert html =~ "70cm"
refute html =~ "from the MQTT firehose"
end
test "switching band filter updates table", %{conn: conn} do
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
_html = render_async(lv)
# Filter to 2m
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
refute html =~ "GRID70"
# Switch to 70cm
html =
lv
|> element("button[phx-value-band=\"70cm\"]")
|> render_click()
refute html =~ "GRID2M"
assert html =~ "GRID70"
end
end
end