prop/test/microwaveprop_web/live/submit_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

444 lines
12 KiB
Elixir

defmodule MicrowavepropWeb.SubmitLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.IemClient
setup do
Req.Test.stub(IemClient, fn conn ->
case conn.request_path do
"/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
_ -> Req.Test.json(conn, %{"profiles" => []})
end
end)
Req.Test.stub(HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
describe "mount" do
test "renders the submission form", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/submit")
assert html =~ "Submit Contact"
assert html =~ "Station 1"
assert html =~ "Station 2"
assert html =~ "Grid 1"
assert html =~ "Grid 2"
assert html =~ "Band"
assert html =~ "Mode"
end
test "rejects CSV upload for unauthenticated users", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
# Switch to CSV tab programmatically (tab button is hidden for anonymous users)
_html = render_hook(lv, "switch_tab", %{"tab" => "csv"})
csv_content =
"station1,station2,grid1,grid2,band,mode,qso_timestamp\nW5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "contacts.csv")
html =
lv
|> form("#csv-upload-form", %{})
|> render_submit()
assert html =~ "Please sign in to upload files"
end
end
describe "switch_tab" do
test "ignores an unknown tab string instead of crashing the LiveView", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
# An unknown tab name used to flow into String.to_existing_atom/1
# and bring the LiveView down. We expect the event to be a no-op
# and the process to stay alive.
render_hook(lv, "switch_tab", %{"tab" => "definitely-not-a-tab-#{System.unique_integer()}"})
assert render(lv) =~ "Submit Contact"
end
test "honors a known tab name", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
html = render_hook(lv, "switch_tab", %{"tab" => "csv"})
# Active tab moved to CSV — the CSV section's heading is now visible.
assert html =~ "CSV"
end
end
describe "validate" do
test "shows validation errors on change", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
html =
lv
|> form("#contact-form", contact: %{station1: ""})
|> render_change()
assert html =~ "can't be blank"
end
end
describe "csv upload" do
setup :register_and_log_in_user
test "renders CSV upload tab and sample download link", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
html =
lv
|> element("[phx-value-tab=csv]")
|> render_click()
assert html =~ "Upload CSV"
assert html =~ "/downloads/sample_contacts.csv"
assert html =~ "Download sample CSV"
end
test "uploads valid CSV and shows preview with confirm button", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=csv]")
|> render_click()
csv_content =
"station1,station2,grid1,grid2,band,mode,qso_timestamp\nW5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "contacts.csv")
html =
lv
|> form("#csv-upload-form", %{})
|> render_submit()
assert html =~ "Review before submitting"
assert html =~ "Looks good"
# Nothing was inserted yet
assert Repo.aggregate(Contact, :count) == 0
lv
|> element("button[phx-click=confirm_csv]")
|> render_click()
{path, _flash} = assert_redirect(lv)
assert path =~ ~r"^/imports/[0-9a-f-]{36}$"
# Oban runs inline, so by the time the redirect fires the chunk
# worker has already inserted the contact.
assert Repo.aggregate(Contact, :count) == 1
end
test "preview flags duplicates before confirmation", %{conn: conn} do
# Seed an existing contact so the next upload is a duplicate of it.
{:ok, _} =
Microwaveprop.Radio.create_contact(%{
"station1" => "W5XD",
"station2" => "K5TR",
"grid1" => "EM12",
"grid2" => "EM00",
"band" => "10000",
"mode" => "CW",
"qso_timestamp" => "2026-03-28T18:00:00Z",
"submitter_email" => "seed@example.com"
})
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=csv]")
|> render_click()
csv_content =
"station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:15:00Z\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "contacts.csv")
html =
lv
|> form("#csv-upload-form", %{})
|> render_submit()
assert html =~ "Duplicates"
assert html =~ "Already in database"
refute html =~ "Looks good"
end
test "preview shows refinements when upload extends an existing grid", %{conn: conn} do
{:ok, existing} =
Microwaveprop.Radio.create_contact(%{
"station1" => "W5XD",
"station2" => "K5TR",
"grid1" => "EM12",
"grid2" => "EM00",
"band" => "10000",
"mode" => "CW",
"qso_timestamp" => "2026-03-28T18:00:00Z",
"submitter_email" => "seed@example.com"
})
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=csv]")
|> render_click()
csv_content =
"station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
"W5XD,K5TR,EM12KP37,EM00CD22,10000,CW,2026-03-28T18:10:00Z\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "contacts.csv")
html =
lv
|> form("#csv-upload-form", %{})
|> render_submit()
assert html =~ "Refinements"
assert html =~ "EM12KP37"
assert html =~ "refine 1 existing"
lv
|> element("button[phx-click=confirm_csv]")
|> render_click()
{path, _flash} = assert_redirect(lv)
assert path =~ ~r"^/imports/[0-9a-f-]{36}$"
assert Repo.aggregate(Contact, :count) == 1
refreshed = Repo.reload(existing)
assert refreshed.grid1 == "EM12KP37"
assert refreshed.grid2 == "EM00CD22"
end
test "shows errors for invalid rows and still offers the confirm button", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=csv]")
|> render_click()
csv_content =
"station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z\n" <>
",,,,,,\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "contacts.csv")
html =
lv
|> form("#csv-upload-form", %{})
|> render_submit()
assert html =~ "Invalid rows"
assert html =~ "Row 3"
assert html =~ "Looks good"
end
test "cancel_csv clears the preview", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=csv]")
|> render_click()
csv_content =
"station1,station2,grid1,grid2,band,mode,qso_timestamp\nW5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "contacts.csv")
lv
|> form("#csv-upload-form", %{})
|> render_submit()
html =
lv
|> element("button[phx-click=cancel_csv]")
|> render_click()
assert html =~ "Upload CSV"
assert Repo.aggregate(Contact, :count) == 0
end
test "ADIF confirm redirects to /imports/:id", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=adif]")
|> render_click()
adif_content =
"<CALL:4>K5TR" <>
"<STATION_CALLSIGN:4>W5XD" <>
"<GRIDSQUARE:4>EM00" <>
"<MY_GRIDSQUARE:4>EM12" <>
"<FREQ:5>10368" <>
"<MODE:2>CW" <>
"<QSO_DATE:8>20260328" <>
"<TIME_ON:6>180000" <>
"<EOR>"
adif_input =
file_input(lv, "#adif-upload-form", :adif, [
%{name: "contacts.adi", content: adif_content, type: "application/octet-stream"}
])
render_upload(adif_input, "contacts.adi")
html =
lv
|> form("#adif-upload-form", %{})
|> render_submit()
assert html =~ "Review before submitting"
lv
|> element("button[phx-click=confirm_csv]")
|> render_click()
{path, _flash} = assert_redirect(lv)
assert path =~ ~r"^/imports/[0-9a-f-]{36}$"
assert Repo.aggregate(Contact, :count) == 1
end
test "handles CSV with no data rows", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> element("[phx-value-tab=csv]")
|> render_click()
csv_content = "station1,station2,grid1,grid2,band,mode,qso_timestamp\n"
csv_input =
file_input(lv, "#csv-upload-form", :csv, [
%{name: "header_only.csv", content: csv_content, type: "text/csv"}
])
render_upload(csv_input, "header_only.csv")
html =
lv
|> form("#csv-upload-form", %{})
|> render_submit()
assert html =~ "CSV file has no data rows"
end
end
describe "save" do
test "creates QSO and redirects on valid submit", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> form("#contact-form",
contact: %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: "2026-03-28T18:00",
mode: "CW",
band: "1296",
grid1: "EM12",
grid2: "EM00",
submitter_email: "test@example.com"
}
)
|> render_submit()
contact = Repo.one!(Contact)
assert contact.station1 == "W5XD"
assert contact.user_submitted == true
assert contact.pos1["lat"]
assert_redirect(lv, ~p"/contacts/#{contact.id}")
end
test "shows errors on invalid submit", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
html =
lv
|> form("#contact-form", contact: %{station1: ""})
|> render_submit()
assert html =~ "can&#39;t be blank"
end
test "persists notes from the form on save", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> form("#contact-form",
contact: %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: "2026-03-28T18:00",
mode: "CW",
band: "1296",
grid1: "EM12",
grid2: "EM00",
submitter_email: "test@example.com",
notes: "Tropo duct along the coast"
}
)
|> render_submit()
contact = Repo.one!(Contact)
assert contact.notes == "Tropo duct along the coast"
end
test "renders a notes textarea on the single-contact tab", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/submit")
assert html =~ ~s(name="contact[notes]")
assert html =~ "textarea"
end
end
end