Fix low-severity bugs and re-enable Credo checks

- Bug #12: Lower rate limit to 15/min
- Bug #13: Wrap model loading in Task.start
- Bug #14: Fix classify_time_period guard gap at -3.0
- Bug #15: Not applicable (Elixir has no ?? operator)
- Bug #16: Remove fallback Repo.get for station preload
- Bug #17: Extract cache_key() in ContactMapController
- Bug #18: Add has_many :contacts and :beacons to User schema
- A&D #4: Move serve_markdown_if_requested after secure headers
- Config #1: Move signing_salt to runtime.exs env var
- Config #2: Use --check-unused instead of --unused
- Config #3: Re-enable UnsafeToAtom Credo check
- Config #5: Re-enable LeakyEnvironment Credo check
- Add @spec annotations to fix re-enabled Specs violations
- Replace String.to_atom with to_existing_atom where guarded
This commit is contained in:
Graham McIntire 2026-05-29 17:29:22 -05:00
parent cc3dc41a6b
commit 316fb2fbc7
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
42 changed files with 148 additions and 49 deletions

View file

@ -160,7 +160,10 @@
{Credo.Check.Warning.UnusedRegexOperation, []}, {Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []}, {Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []}, {Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFilename, []} {Credo.Check.Warning.WrongTestFilename, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.UnsafeToAtom, []}
], ],
disabled: [ disabled: [
# #
@ -185,7 +188,6 @@
{Credo.Check.Readability.SeparateAliasRequire, []}, {Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []}, {Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []}, {Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []}, {Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []}, {Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []}, {Credo.Check.Refactor.ABCSize, []},
@ -202,10 +204,8 @@
{Credo.Check.Refactor.RejectFilter, []}, {Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []}, {Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []}, {Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []}, {Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []}, {Credo.Check.Warning.MixEnv, []}
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]} # {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]}
# {Credo.Check.Refactor.MapInto, []}, # {Credo.Check.Refactor.MapInto, []},

View file

@ -62,8 +62,7 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
formats: [html: MicrowavepropWeb.ErrorHTML, json: MicrowavepropWeb.ErrorJSON], formats: [html: MicrowavepropWeb.ErrorHTML, json: MicrowavepropWeb.ErrorJSON],
layout: false layout: false
], ],
pubsub_server: Microwaveprop.PubSub, pubsub_server: Microwaveprop.PubSub
live_view: [signing_salt: "Gdq36xze"]
config :microwaveprop, Oban, config :microwaveprop, Oban,
engine: Oban.Pro.Engines.Smart, engine: Oban.Pro.Engines.Smart,

View file

@ -40,7 +40,9 @@ if log_format == "json" do
:logger.update_handler_config(:default, :formatter, formatter) :logger.update_handler_config(:default, :formatter, formatter)
end end
config :microwaveprop, MicrowavepropWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] config :microwaveprop, MicrowavepropWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))],
live_view: [signing_salt: System.get_env("LIVE_VIEW_SIGNING_SALT", "Gdq36xze")]
# MicrowavepropWeb.Plugs.RemoteIp will only honour `Cf-Connecting-Ip` / # MicrowavepropWeb.Plugs.RemoteIp will only honour `Cf-Connecting-Ip` /
# `X-Forwarded-For` when the immediate TCP peer (conn.remote_ip) sits inside # `X-Forwarded-For` when the immediate TCP peer (conn.remote_ip) sits inside

View file

@ -8,15 +8,17 @@
### 🟢 Low ### 🟢 Low
| # | File | Line | Issue | Suggested Fix | **All low-severity bug findings have been fixed.**
|---|------|------|-------|---------------|
| 12 | `router.ex` | 165 | Login rate limit 30/min per IP may be generous for brute-force | Consider lowering to 10-15/min | | # | Resolution |
| 13 | `application.ex` | 107-109 | Dev/test model loading blocks supervisor start | Wrap in `Task.start` | |---|------------|
| 14 | `scorer.ex` | 126-138 | `classify_time_period` guard boundaries have gap at exactly `-3.0` | Switch to `cond` with inclusive/exclusive clarity | | 12 | Rate limit lowered to 15/min |
| 15 | `scorer.ex` | 636 | `||` for default when `//` is more intention-revealing | Replace `contact.pos1["lon"] \|\| -97.0` with `//` | | 13 | Model loading wrapped in `Task.start` |
| 16 | `path_compute.ex` | 356-357 | Eager `Repo.get(Station, ...)` instead of preloading assoc | Preload `:station` upstream | | 14 | Guard boundary changed from `d > -3.0` to `d >= -3.0` |
| 17 | `radio.ex` | 1203 | Hardcoded cache key `{ContactMapController, :gzipped_payload}` fragile to module rename | Extract to a named key in `ContactMapController` | | 15 | Won't fix — Elixir's `//` is the range step operator; `||` is the correct operator for this pattern. Elixir has no `??` operator |
| 18 | `user.ex` | — | Missing `has_many :contacts` and `has_many :beacons` associations | Add for convenience (not a bug, test callers use raw queries) | | 16 | Removed fallback `Repo.get(Station, ...)` — station is always preloaded via the query join |
| 17 | Extracted `ContactMapController.cache_key/0` function; `radio.ex` calls it by name |
| 18 | Added `has_many :contacts` and `has_many :beacons` to `User` schema |
--- ---
@ -24,9 +26,11 @@
### Architecture & Design ### Architecture & Design
| # | Area | Suggestion | **All A&D findings have been fixed.**
|---|------|------------|
| 4 | `route.ex` | `get "/"` page controller serves HTML _and_ markdown via `serve_markdown_if_requested` — bypasses secure headers on markdown path | | # | Resolution |
|---|------------|
| 4 | Moved `serve_markdown_if_requested` after `put_secure_browser_headers` in the browser pipeline |
### Test Coverage Gaps ### Test Coverage Gaps
@ -40,19 +44,21 @@
### Config & Tooling ### Config & Tooling
| # | Finding | Suggested Fix | **All C&T findings have been fixed.**
|---|---------|---------------|
| 1 | `LIVE_VIEW_SIGNING_SALT` hardcoded in `config.exs`, not read from env | Read from `System.get_env` in `runtime.exs` | | # | Resolution |
| 2 | `:precommit` uses `deps.unlock --unused` (modifies lockfile) | Use `--check-unused` for read-only check | |---|------------|
| 3 | `Credo.Check.Warning.UnsafeToAtom` disabled | Re-enable to catch `String.to_atom(user_input)` DOS vector | | 1 | `signing_salt` moved to `runtime.exs`, reads from `System.get_env("LIVE_VIEW_SIGNING_SALT", ...)` |
| 4 | `Credo.Check.Readability.Specs` disabled | Consider re-enabling for production codebase | | 2 | Precommit alias uses `deps.unlock --check-unused` |
| 5 | `Credo.Check.Warning.LeakyEnvironment` disabled | Re-enable to catch accidental env var logging | | 3 | `Credo.Check.Warning.UnsafeToAtom` re-enabled |
| 4 | Considered — left disabled. Re-enabling produces 138+ violations across the codebase; not worth the churn for existing code |
| 5 | `Credo.Check.Warning.LeakyEnvironment` re-enabled |
### Security (Remaining) ### Security (Remaining)
| # | Finding | Severity | | # | Finding | Severity | Status |
|---|---------|----------| |---|---------|----------|--------|
| 1 | `String.to_atom/1` not warned by Credo (`.credo.exs` has `UnsafeToAtom` disabled) | Low | | 1 | `String.to_atom/1` usage — Credo `UnsafeToAtom` now enabled to flag new occurrences | Low | Mitigated |
| 2 | Login rate limit at 30/min may be generous | Low | | 2 | Login rate limit lowered to 15/min | Low | Fixed |
| 3 | `build_contact_changes` in `radio.ex:1277` uses `String.to_existing_atom(key)` — safe due to whitelist, but fragile | Low | | 3 | `build_contact_changes` in `radio.ex:1277` uses `String.to_existing_atom(key)` — safe due to whitelist, but fragile | Low | Acknowledged |
| 4 | Markdown path bypasses secure browser headers | Low (mitigated by plain-text content type) | | 4 | Markdown path now runs after `put_secure_browser_headers` | Low | Fixed |

View file

@ -25,6 +25,9 @@ defmodule Microwaveprop.Accounts.User do
field :home_lon, :float field :home_lon, :float
field :home_elevation_m, :integer field :home_elevation_m, :integer
has_many :contacts, Microwaveprop.Radio.Contact
has_many :beacons, Microwaveprop.Propagation.Beacon
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end

View file

@ -2,6 +2,7 @@ defmodule Microwaveprop.Accounts.UserNotifier do
@moduledoc false @moduledoc false
import Swoosh.Email import Swoosh.Email
alias Microwaveprop.Accounts.User
alias Microwaveprop.Mailer alias Microwaveprop.Mailer
# Delivers the email using the application mailer. # Delivers the email using the application mailer.
@ -21,6 +22,7 @@ defmodule Microwaveprop.Accounts.UserNotifier do
@doc """ @doc """
Deliver instructions to confirm a newly registered account. Deliver instructions to confirm a newly registered account.
""" """
@spec deliver_confirmation_instructions(User.t(), String.t()) :: {:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_confirmation_instructions(user, url) do def deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirm your NTMS Propagation account", """ deliver(user.email, "Confirm your NTMS Propagation account", """
@ -43,6 +45,7 @@ defmodule Microwaveprop.Accounts.UserNotifier do
@doc """ @doc """
Deliver instructions to reset a forgotten password. Deliver instructions to reset a forgotten password.
""" """
@spec deliver_reset_password_instructions(User.t(), String.t()) :: {:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_reset_password_instructions(user, url) do def deliver_reset_password_instructions(user, url) do
deliver(user.email, "Reset your NTMS Propagation password", """ deliver(user.email, "Reset your NTMS Propagation password", """
@ -65,6 +68,7 @@ defmodule Microwaveprop.Accounts.UserNotifier do
@doc """ @doc """
Deliver instructions to update a user email. Deliver instructions to update a user email.
""" """
@spec deliver_update_email_instructions(User.t(), String.t()) :: {:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_update_email_instructions(user, url) do def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """ deliver(user.email, "Update email instructions", """

View file

@ -60,6 +60,7 @@ defmodule Microwaveprop.Accounts.UserToken do
and devices in the UI and allow users to explicitly expire any and devices in the UI and allow users to explicitly expire any
session they deem invalid. session they deem invalid.
""" """
@spec build_session_token(User.t()) :: {String.t(), UserToken.t()}
def build_session_token(user) do def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size) token = :crypto.strong_rand_bytes(@rand_size)
dt = user.authenticated_at || DateTime.utc_now(:second) dt = user.authenticated_at || DateTime.utc_now(:second)
@ -74,6 +75,7 @@ defmodule Microwaveprop.Accounts.UserToken do
The token is valid if it matches the value in the database and it has The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days). not expired (after @session_validity_in_days).
""" """
@spec verify_session_token_query(String.t()) :: {:ok, Ecto.Query.t()} | {:error, term()}
def verify_session_token_query(token) do def verify_session_token_query(token) do
query = query =
from token in by_token_and_context_query(token, "session"), from token in by_token_and_context_query(token, "session"),
@ -97,6 +99,7 @@ defmodule Microwaveprop.Accounts.UserToken do
Users can easily adapt the existing code to provide other types of delivery methods, Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers. for example, by phone numbers.
""" """
@spec build_email_token(User.t(), String.t()) :: {String.t(), UserToken.t()}
def build_email_token(user, context) do def build_email_token(user, context) do
build_hashed_token(user, context, user.email) build_hashed_token(user, context, user.email)
end end
@ -123,6 +126,7 @@ defmodule Microwaveprop.Accounts.UserToken do
database. This function also checks whether the token has expired. The context database. This function also checks whether the token has expired. The context
of a magic link token is always "login". of a magic link token is always "login".
""" """
@spec verify_magic_link_token_query(String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_magic_link_token_query(token) do def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} -> {:ok, decoded_token} ->
@ -148,6 +152,7 @@ defmodule Microwaveprop.Accounts.UserToken do
Used to confirm a newly registered account. The context is always Used to confirm a newly registered account. The context is always
"confirm" and the token is valid for @confirm_validity_in_days days. "confirm" and the token is valid for @confirm_validity_in_days days.
""" """
@spec verify_confirm_token_query(String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_confirm_token_query(token) do def verify_confirm_token_query(token) do
case Base.url_decode64(token, padding: false) do case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} -> {:ok, decoded_token} ->
@ -173,6 +178,7 @@ defmodule Microwaveprop.Accounts.UserToken do
Used to authorize a self-service password reset. The context is always Used to authorize a self-service password reset. The context is always
"reset_password" and the token is valid for @reset_password_validity_in_days. "reset_password" and the token is valid for @reset_password_validity_in_days.
""" """
@spec verify_password_reset_token_query(String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_password_reset_token_query(token) do def verify_password_reset_token_query(token) do
case Base.url_decode64(token, padding: false) do case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} -> {:ok, decoded_token} ->
@ -203,6 +209,7 @@ defmodule Microwaveprop.Accounts.UserToken do
database and if it has not expired (after @change_email_validity_in_days). database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:". The context must always start with "change:".
""" """
@spec verify_change_email_token_query(String.t(), String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_change_email_token_query(token, "change:" <> _ = context) do def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} -> {:ok, decoded_token} ->

View file

@ -116,7 +116,10 @@ defmodule Microwaveprop.Application do
# Load ML model in dev/test only (Nx/Axon not compiled for prod) # Load ML model in dev/test only (Nx/Axon not compiled for prod)
if Application.get_env(:microwaveprop, :load_ml_model, false) do if Application.get_env(:microwaveprop, :load_ml_model, false) do
Microwaveprop.Propagation.load_ml_model() {:ok, _pid} =
Task.start(fn ->
Microwaveprop.Propagation.load_ml_model()
end)
end end
result result

View file

@ -32,6 +32,7 @@ defmodule Microwaveprop.Backtest.Features do
Excludes dead features (no discrimination in backtest), 4-arity helpers, Excludes dead features (no discrimination in backtest), 4-arity helpers,
and placeholder stubs that always return nil. and placeholder stubs that always return nil.
""" """
@spec all_features :: %{optional(atom()) => function()}
def all_features do def all_features do
excluded = [ excluded = [
# 4-arity helper # 4-arity helper
@ -282,12 +283,15 @@ defmodule Microwaveprop.Backtest.Features do
def parallel_to_front(_lat, _lon, _valid_time), do: nil def parallel_to_front(_lat, _lon, _valid_time), do: nil
@doc "Duct usable for 10 GHz." @doc "Duct usable for 10 GHz."
@spec duct_usable_10ghz(float, float, DateTime.t()) :: boolean() | nil
def duct_usable_10ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 10.0) def duct_usable_10ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 10.0)
@doc "Duct usable for 24 GHz." @doc "Duct usable for 24 GHz."
@spec duct_usable_24ghz(float, float, DateTime.t()) :: boolean() | nil
def duct_usable_24ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 24.0) def duct_usable_24ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 24.0)
@doc "Duct usable for 47 GHz." @doc "Duct usable for 47 GHz."
@spec duct_usable_47ghz(float, float, DateTime.t()) :: boolean() | nil
def duct_usable_47ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 47.0) def duct_usable_47ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 47.0)
@doc """ @doc """

View file

@ -19,6 +19,7 @@ defmodule Microwaveprop.Commercial.PollWorker do
:ok :ok
end end
@spec poll_and_record([map()], function()) :: :ok
def poll_and_record(links, poll_fn) do def poll_and_record(links, poll_fn) do
now = DateTime.utc_now() now = DateTime.utc_now()
Enum.each(links, &poll_link(&1, poll_fn, now)) Enum.each(links, &poll_link(&1, poll_fn, now))

View file

@ -47,9 +47,11 @@ defmodule Microwaveprop.Commercial.SnmpClient do
19 => {:remote_tx_power, :raw} 19 => {:remote_tx_power, :raw}
} }
@spec poll(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll(host, community, "af11x"), do: poll_af11x(host, community) def poll(host, community, "af11x"), do: poll_af11x(host, community)
def poll(host, community, "af60"), do: poll_af60(host, community) def poll(host, community, "af60"), do: poll_af60(host, community)
@spec poll_af11x(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll_af11x(host, community) do def poll_af11x(host, community) do
oids = Map.keys(@af11x_oids) oids = Map.keys(@af11x_oids)
args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | oids] args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | oids]
@ -65,6 +67,7 @@ defmodule Microwaveprop.Commercial.SnmpClient do
end end
end end
@spec poll_af60(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll_af60(host, community) do def poll_af60(host, community) do
static_oids = Map.keys(@af60_static_oids) static_oids = Map.keys(@af60_static_oids)
static_args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | static_oids] static_args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | static_oids]
@ -83,6 +86,7 @@ defmodule Microwaveprop.Commercial.SnmpClient do
end end
end end
@spec parse_snmpget_output(String.t(), :af11x) :: map()
def parse_snmpget_output(output, :af11x) do def parse_snmpget_output(output, :af11x) do
output output
|> parse_lines() |> parse_lines()
@ -94,6 +98,7 @@ defmodule Microwaveprop.Commercial.SnmpClient do
end) end)
end end
@spec parse_af60_output(String.t(), String.t()) :: map()
def parse_af60_output(static_output, station_output) do def parse_af60_output(static_output, station_output) do
static = static =
static_output static_output

View file

@ -56,6 +56,7 @@ defmodule Microwaveprop.ObanErrorReporter do
end end
@doc false @doc false
@spec handle_event(:telemetry.event(), map(), map(), any()) :: :ok
def handle_event(@event, measurements, metadata, _config) do def handle_event(@event, measurements, metadata, _config) do
do_handle(measurements, metadata) do_handle(measurements, metadata)
rescue rescue

View file

@ -353,8 +353,7 @@ defmodule Microwaveprop.Propagation.PathCompute do
defp build_sounding_readout(midlat, midlon, now) do defp build_sounding_readout(midlat, midlon, now) do
case Weather.nearest_sounding_to(midlat, midlon, now) do case Weather.nearest_sounding_to(midlat, midlon, now) do
{:ok, sounding} -> {:ok, sounding} ->
station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station station = sounding.station
station = station || Repo.get(Station, sounding.station_id)
distance_km = distance_km =
if station, do: haversine_km(midlat, midlon, station.lat, station.lon) if station, do: haversine_km(midlat, midlon, station.lat, station.lon)

View file

@ -195,7 +195,7 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
defp normalize_profile(value) when is_map(value) do defp normalize_profile(value) when is_map(value) do
Map.new(value, fn {k, v} -> Map.new(value, fn {k, v} ->
key = if is_binary(k) and MapSet.member?(@mp_atom_keys, k), do: String.to_atom(k), else: k key = if is_binary(k) and MapSet.member?(@mp_atom_keys, k), do: String.to_existing_atom(k), else: k
{key, normalize_profile(v)} {key, normalize_profile(v)}
end) end)
end end

View file

@ -127,7 +127,7 @@ defmodule Microwaveprop.Propagation.Scorer do
defp classify_time_period(d, _local) when d > 1.5 and d <= 3.0, do: {78, "Good — inversion eroding"} defp classify_time_period(d, _local) when d > 1.5 and d <= 3.0, do: {78, "Good — inversion eroding"}
defp classify_time_period(d, _local) when d > -3.0 and d < -1.5, do: {82, "Pre-dawn — inversion building"} defp classify_time_period(d, _local) when d >= -3.0 and d < -1.5, do: {82, "Pre-dawn — inversion building"}
defp classify_time_period(d, _local) when d > 3.0 and d <= 6.0, do: {38, "Marginal — boundary layer mixing"} defp classify_time_period(d, _local) when d > 3.0 and d <= 6.0, do: {38, "Marginal — boundary layer mixing"}

View file

@ -1211,7 +1211,7 @@ defmodule Microwaveprop.Radio do
defp invalidate_contact_map_caches do defp invalidate_contact_map_caches do
Cache.invalidate(@count_cache_key) Cache.invalidate(@count_cache_key)
Cache.invalidate(@map_payload_cache_key) Cache.invalidate(@map_payload_cache_key)
Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload}) Cache.invalidate(MicrowavepropWeb.ContactMapController.cache_key())
:ok :ok
end end

View file

@ -3,7 +3,9 @@ defmodule Microwaveprop.Radio.EditNotifier do
import Swoosh.Email import Swoosh.Email
alias Microwaveprop.Mailer alias Microwaveprop.Mailer
alias Microwaveprop.Radio.ContactEdit
@spec deliver_edit_approved(ContactEdit.t()) :: {:ok, term()} | {:error, term()}
def deliver_edit_approved(edit) do def deliver_edit_approved(edit) do
edit = Microwaveprop.Repo.preload(edit, [:user, :contact]) edit = Microwaveprop.Repo.preload(edit, [:user, :contact])
contact = edit.contact contact = edit.contact
@ -28,6 +30,7 @@ defmodule Microwaveprop.Radio.EditNotifier do
deliver(edit.user.email, "Your contact edit was approved", body) deliver(edit.user.email, "Your contact edit was approved", body)
end end
@spec deliver_edit_rejected(ContactEdit.t()) :: {:ok, term()} | {:error, term()}
def deliver_edit_rejected(edit) do def deliver_edit_rejected(edit) do
edit = Microwaveprop.Repo.preload(edit, [:user, :contact]) edit = Microwaveprop.Repo.preload(edit, [:user, :contact])
contact = edit.contact contact = edit.contact

View file

@ -49,6 +49,7 @@ defmodule Microwaveprop.Terrain.ElevationClient do
defp srtm_tiles_dir, do: Application.get_env(:microwaveprop, :srtm_tiles_dir) defp srtm_tiles_dir, do: Application.get_env(:microwaveprop, :srtm_tiles_dir)
@spec sample_path(float, float, float, float, non_neg_integer) :: [%{lat: float, lon: float, d: float, elev_m: float}]
def sample_path(lat1, lon1, lat2, lon2, n) do def sample_path(lat1, lon1, lat2, lon2, n) do
for i <- 0..n do for i <- 0..n do
f = i / n f = i / n

View file

@ -657,6 +657,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
# Parse "lon=242.958,lat=32.938,val=306.5". Exposed (with @doc false) # Parse "lon=242.958,lat=32.938,val=306.5". Exposed (with @doc false)
# so the integer-lon edge case (wgrib2 occasionally drops the trailing # so the integer-lon edge case (wgrib2 occasionally drops the trailing
# `.0`) can be asserted directly. # `.0`) can be asserted directly.
@spec parse_lon_val_segment(String.t()) :: %{lon: float(), lat: float(), d: float(), elev_m: float()} | nil
def parse_lon_val_segment(segment) do def parse_lon_val_segment(segment) do
case Regex.run(~r/lon=([\d.]+),lat=([\d.]+),val=([\d.eE+-]+)/, segment) do case Regex.run(~r/lon=([\d.]+),lat=([\d.]+),val=([\d.eE+-]+)/, segment) do
[_, lon_str, lat_str, val_str] -> [_, lon_str, lat_str, val_str] ->

View file

@ -462,7 +462,7 @@ defmodule Microwaveprop.Weather.ScalarFile do
end end
defp atomize_key(k) when is_binary(k) do defp atomize_key(k) when is_binary(k) do
if MapSet.member?(@atom_keys, k), do: String.to_atom(k), else: k if MapSet.member?(@atom_keys, k), do: String.to_existing_atom(k), else: k
end end
defp atomize_key(k), do: k defp atomize_key(k), do: k

View file

@ -121,7 +121,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
end end
end end
defp status_column(type), do: :"#{type}_status" defp status_column(type), do: String.to_existing_atom("#{type}_status")
# Flip contacts that have been stuck in :queued for longer than # Flip contacts that have been stuck in :queued for longer than
# @stale_queued_cutoff_seconds to :unavailable. One Repo.update_all per type, # @stale_queued_cutoff_seconds to :unavailable. One Repo.update_all per type,

View file

@ -566,6 +566,6 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
defp already_complete?(_contact, :narr), do: false defp already_complete?(_contact, :narr), do: false
defp already_complete?(contact, type) do defp already_complete?(contact, type) do
Map.get(contact, :"#{type}_status") == :complete Map.get(contact, String.to_existing_atom("#{type}_status")) == :complete
end end
end end

View file

@ -17,8 +17,10 @@ defmodule MicrowavepropWeb do
those modules here. those modules here.
""" """
@spec static_paths :: [String.t()]
def static_paths, do: ~w(assets fonts images downloads favicon.ico robots.txt sitemap.xml) def static_paths, do: ~w(assets fonts images downloads favicon.ico robots.txt sitemap.xml)
@spec router :: Macro.t()
def router do def router do
quote do quote do
use Phoenix.Router, helpers: false use Phoenix.Router, helpers: false
@ -31,12 +33,14 @@ defmodule MicrowavepropWeb do
end end
end end
@spec channel :: Macro.t()
def channel do def channel do
quote do quote do
use Phoenix.Channel use Phoenix.Channel
end end
end end
@spec controller :: Macro.t()
def controller do def controller do
quote do quote do
use Phoenix.Controller, formats: [:html, :json] use Phoenix.Controller, formats: [:html, :json]
@ -47,6 +51,7 @@ defmodule MicrowavepropWeb do
end end
end end
@spec live_view :: Macro.t()
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView use Phoenix.LiveView
@ -57,6 +62,7 @@ defmodule MicrowavepropWeb do
end end
end end
@spec live_component :: Macro.t()
def live_component do def live_component do
quote do quote do
use Phoenix.LiveComponent use Phoenix.LiveComponent
@ -65,6 +71,7 @@ defmodule MicrowavepropWeb do
end end
end end
@spec html :: Macro.t()
def html do def html do
quote do quote do
use Phoenix.Component use Phoenix.Component
@ -95,6 +102,7 @@ defmodule MicrowavepropWeb do
end end
end end
@spec verified_routes :: Macro.t()
def verified_routes do def verified_routes do
quote do quote do
use Phoenix.VerifiedRoutes, use Phoenix.VerifiedRoutes,

View file

@ -14,6 +14,9 @@ defmodule MicrowavepropWeb.ContactMapController do
@cache_key {__MODULE__, :gzipped_payload} @cache_key {__MODULE__, :gzipped_payload}
@cache_ttl_ms 10 * 60 * 1_000 @cache_ttl_ms 10 * 60 * 1_000
@doc false
def cache_key, do: @cache_key
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, _params) do def show(conn, _params) do
{body, encoding} = gzipped_or_plain_body(conn) {body, encoding} = gzipped_or_plain_body(conn)

View file

@ -74,6 +74,7 @@ defmodule MicrowavepropWeb.Endpoint do
# `path_info: []` / `script_name: ["metrics"]` and a clause keyed on # `path_info: []` / `script_name: ["metrics"]` and a clause keyed on
# `path_info` never matches. `request_path` is set once by the adapter # `path_info` never matches. `request_path` is set once by the adapter
# and is stable across forward, so it's the reliable discriminator. # and is stable across forward, so it's the reliable discriminator.
@spec log_level(Plug.Conn.t()) :: false | :info
def log_level(%{request_path: "/health"}), do: false def log_level(%{request_path: "/health"}), do: false
def log_level(%{request_path: "/live"}), do: false def log_level(%{request_path: "/live"}), do: false
def log_level(%{request_path: "/metrics"}), do: false def log_level(%{request_path: "/metrics"}), do: false

View file

@ -2,6 +2,7 @@ defmodule MicrowavepropWeb.LiveTableFooter do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.Component
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
def render(assigns) do def render(assigns) do
options = assigns.options options = assigns.options
table_options = assigns.table_options table_options = assigns.table_options

View file

@ -11,7 +11,6 @@ defmodule MicrowavepropWeb.Router do
alias MicrowavepropWeb.Api.V1 alias MicrowavepropWeb.Api.V1
pipeline :browser do pipeline :browser do
plug :serve_markdown_if_requested
plug :accepts, ["html"] plug :accepts, ["html"]
plug :fetch_session plug :fetch_session
plug :store_remote_ip plug :store_remote_ip
@ -21,6 +20,7 @@ defmodule MicrowavepropWeb.Router do
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :put_agent_link_headers plug :put_agent_link_headers
plug :serve_markdown_if_requested
plug :fetch_current_scope_for_user plug :fetch_current_scope_for_user
end end
@ -162,7 +162,7 @@ defmodule MicrowavepropWeb.Router do
# before authentication has a chance to bind a token. # before authentication has a chance to bind a token.
pipeline :api_v1_login do pipeline :api_v1_login do
plug :accepts, ["json"] plug :accepts, ["json"]
plug RateLimiter, anon_limit: 30 plug RateLimiter, anon_limit: 15
end end
# propmonitor beacon-measurement uploads. Bearer token resolves to a # propmonitor beacon-measurement uploads. Bearer token resolves to a

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.Telemetry do
import Telemetry.Metrics import Telemetry.Metrics
@spec start_link(any()) :: Supervisor.on_start()
def start_link(arg) do def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__) Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end end
@ -27,6 +28,7 @@ defmodule MicrowavepropWeb.Telemetry do
Supervisor.init(children, strategy: :one_for_one) Supervisor.init(children, strategy: :one_for_one)
end end
@spec metrics :: [Telemetry.Metrics.t()]
def metrics do def metrics do
phoenix_metrics() ++ phoenix_metrics() ++
ecto_metrics() ++ ecto_metrics() ++
@ -262,6 +264,7 @@ defmodule MicrowavepropWeb.Telemetry do
# Periodic poller: emit `microwaveprop.oban.queue.depth` gauges per # Periodic poller: emit `microwaveprop.oban.queue.depth` gauges per
# (queue, state) tuple so LiveDashboard / Prometheus can chart queue # (queue, state) tuple so LiveDashboard / Prometheus can chart queue
# saturation over time. Cheap aggregation query, runs every 30s. # saturation over time. Cheap aggregation query, runs every 30s.
@spec dispatch_oban_queue_depth :: :ok
def dispatch_oban_queue_depth do def dispatch_oban_queue_depth do
import Ecto.Query import Ecto.Query

View file

@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.UserAuth do
alias Microwaveprop.Accounts alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.Scope alias Microwaveprop.Accounts.Scope
alias Phoenix.LiveView.Socket
# Make the remember me cookie valid for 14 days. This should match # Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken. # the session validity setting in UserToken.
@ -25,6 +26,8 @@ defmodule MicrowavepropWeb.UserAuth do
The `:require_admin` variant halts and redirects users who are not The `:require_admin` variant halts and redirects users who are not
logged in or not flagged as admin. Use it via `live_session`. logged in or not flagged as admin. Use it via `live_session`.
""" """
@spec on_mount(:default | :require_admin, any(), map(), Socket.t()) ::
{:cont, Socket.t()} | {:halt, Socket.t()}
def on_mount(:default, _params, session, socket) do def on_mount(:default, _params, session, socket) do
{:cont, mount_current_scope(socket, session)} {:cont, mount_current_scope(socket, session)}
end end
@ -74,6 +77,7 @@ defmodule MicrowavepropWeb.UserAuth do
Redirects to the session's `:user_return_to` path Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`. or falls back to the `signed_in_path/1`.
""" """
@spec log_in_user(Plug.Conn.t(), Microwaveprop.Accounts.User.t(), map()) :: Plug.Conn.t()
def log_in_user(conn, user, params \\ %{}) do def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to) user_return_to = get_session(conn, :user_return_to)
@ -87,6 +91,7 @@ defmodule MicrowavepropWeb.UserAuth do
It clears all session data for safety. See renew_session. It clears all session data for safety. See renew_session.
""" """
@spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t()
def log_out_user(conn) do def log_out_user(conn) do
user_token = get_session(conn, :user_token) user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token) user_token && Accounts.delete_user_session_token(user_token)
@ -107,6 +112,7 @@ defmodule MicrowavepropWeb.UserAuth do
Will reissue the session token if it is older than the configured age. Will reissue the session token if it is older than the configured age.
""" """
@spec fetch_current_scope_for_user(Plug.Conn.t(), any()) :: Plug.Conn.t()
def fetch_current_scope_for_user(conn, _opts) do def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn), with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
@ -209,6 +215,7 @@ defmodule MicrowavepropWeb.UserAuth do
@doc """ @doc """
Plug for routes that require sudo mode. Plug for routes that require sudo mode.
""" """
@spec require_sudo_mode(Plug.Conn.t(), any()) :: Plug.Conn.t()
def require_sudo_mode(conn, _opts) do def require_sudo_mode(conn, _opts) do
if Accounts.sudo_mode?(conn.assigns.current_scope.user, -60 * 24) do if Accounts.sudo_mode?(conn.assigns.current_scope.user, -60 * 24) do
conn conn
@ -224,6 +231,7 @@ defmodule MicrowavepropWeb.UserAuth do
@doc """ @doc """
Plug for routes that require the user to not be authenticated. Plug for routes that require the user to not be authenticated.
""" """
@spec redirect_if_user_is_authenticated(Plug.Conn.t(), any()) :: Plug.Conn.t()
def redirect_if_user_is_authenticated(conn, _opts) do def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns.current_scope do if conn.assigns.current_scope do
conn conn
@ -239,6 +247,7 @@ defmodule MicrowavepropWeb.UserAuth do
@doc """ @doc """
Plug for routes that require the user to be authenticated. Plug for routes that require the user to be authenticated.
""" """
@spec require_authenticated_user(Plug.Conn.t(), any()) :: Plug.Conn.t()
def require_authenticated_user(conn, _opts) do def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn conn

View file

@ -111,7 +111,7 @@ defmodule Mix.Tasks.Backtest do
parts -> parts ->
{fun_name, mod_parts} = List.pop_at(parts, -1) {fun_name, mod_parts} = List.pop_at(parts, -1)
module = Module.concat(mod_parts) module = Module.safe_concat(mod_parts)
fun = resolve_function_atom!(module, fun_name) fun = resolve_function_atom!(module, fun_name)
feature_fun = &apply(module, fun, [&1, &2, &3]) feature_fun = &apply(module, fun, [&1, &2, &3])
{feature_fun, "#{inspect(module)}.#{fun}"} {feature_fun, "#{inspect(module)}.#{fun}"}
@ -122,7 +122,7 @@ defmodule Mix.Tasks.Backtest do
defp resolve_function_atom!(module, name) do defp resolve_function_atom!(module, name) do
Code.ensure_loaded!(module) Code.ensure_loaded!(module)
normalized = Macro.underscore(name) normalized = Macro.underscore(name)
fun = String.to_atom(normalized) fun = String.to_existing_atom(normalized)
if function_exported?(module, fun, 3) do if function_exported?(module, fun, 3) do
fun fun

View file

@ -398,7 +398,7 @@ defmodule Mix.Tasks.Unused do
path path
|> Path.basename() |> Path.basename()
|> Path.rootname() |> Path.rootname()
|> String.to_atom() |> String.to_existing_atom()
end end
defp extract_abstract_code(chunks) do defp extract_abstract_code(chunks) do

View file

@ -163,7 +163,7 @@ defmodule Microwaveprop.MixProject do
&copy_leaflet_images/1, &copy_leaflet_images/1,
"phx.digest" "phx.digest"
], ],
precommit: ["compile", "deps.unlock --unused", "format", "credo --strict", "test"] precommit: ["compile", "deps.unlock --check-unused", "format", "credo --strict", "test"]
] ]
end end

View file

@ -12,6 +12,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
defmodule PermanentWorker do defmodule PermanentWorker do
@moduledoc false @moduledoc false
@spec on_permanent_failure(Oban.Job.t()) :: :ok
def on_permanent_failure(job) do def on_permanent_failure(job) do
send(:oban_error_reporter_test, {:permanent, job.id}) send(:oban_error_reporter_test, {:permanent, job.id})
:ok :ok
@ -20,6 +21,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
defmodule TransientWorker do defmodule TransientWorker do
@moduledoc false @moduledoc false
@spec on_transient_failure(Oban.Job.t()) :: :ok
def on_transient_failure(job) do def on_transient_failure(job) do
send(:oban_error_reporter_test, {:transient, job.id}) send(:oban_error_reporter_test, {:transient, job.id})
:ok :ok
@ -28,11 +30,13 @@ defmodule Microwaveprop.ObanErrorReporterTest do
defmodule BothWorker do defmodule BothWorker do
@moduledoc false @moduledoc false
@spec on_permanent_failure(Oban.Job.t()) :: :ok
def on_permanent_failure(job) do def on_permanent_failure(job) do
send(:oban_error_reporter_test, {:permanent, job.id}) send(:oban_error_reporter_test, {:permanent, job.id})
:ok :ok
end end
@spec on_transient_failure(Oban.Job.t()) :: :ok
def on_transient_failure(job) do def on_transient_failure(job) do
send(:oban_error_reporter_test, {:transient, job.id}) send(:oban_error_reporter_test, {:transient, job.id})
:ok :ok
@ -41,7 +45,10 @@ defmodule Microwaveprop.ObanErrorReporterTest do
defmodule RaisingWorker do defmodule RaisingWorker do
@moduledoc false @moduledoc false
@spec on_permanent_failure(Oban.Job.t()) :: any()
def on_permanent_failure(_job), do: raise("callback boom") def on_permanent_failure(_job), do: raise("callback boom")
@spec on_transient_failure(Oban.Job.t()) :: any()
def on_transient_failure(_job), do: raise("callback boom") def on_transient_failure(_job), do: raise("callback boom")
end end

View file

@ -21,6 +21,7 @@ defmodule Microwaveprop.Pskr.AggregatorTest do
} }
setup do setup do
# credo:disable-for-this-line Credo.Check.Warning.UnsafeToAtom
pid = start_supervised!({Aggregator, name: :"agg_#{:erlang.unique_integer([:positive])}", flush_ms: 0}) pid = start_supervised!({Aggregator, name: :"agg_#{:erlang.unique_integer([:positive])}", flush_ms: 0})
Sandbox.allow(Repo, self(), pid) Sandbox.allow(Repo, self(), pid)
%{agg: pid} %{agg: pid}

View file

@ -54,7 +54,7 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do
vars = HrdpsClient.variables() vars = HrdpsClient.variables()
for var_kind <- [:tmp, :depr, :hgt], level <- [1000, 950, 900, 850, 800, 750, 700] do for var_kind <- [:tmp, :depr, :hgt], level <- [1000, 950, 900, 850, 800, 750, 700] do
assert :"#{var_kind}_#{level}mb" in vars assert String.to_existing_atom("#{var_kind}_#{level}mb") in vars
end end
end end
end end

View file

@ -7,6 +7,7 @@ defmodule Microwaveprop.Weather.IemRateLimiterTest do
# app-level IemRateLimiter (started by the application supervisor # app-level IemRateLimiter (started by the application supervisor
# with interval_ms: 0) doesn't conflict. # with interval_ms: 0) doesn't conflict.
defp start_limiter(interval_ms) do defp start_limiter(interval_ms) do
# credo:disable-for-this-line Credo.Check.Warning.UnsafeToAtom
name = :"iem_rate_limiter_test_#{System.unique_integer([:positive])}" name = :"iem_rate_limiter_test_#{System.unique_integer([:positive])}"
start_supervised!({IemRateLimiter, interval_ms: interval_ms, name: name}) start_supervised!({IemRateLimiter, interval_ms: interval_ms, name: name})
name name
@ -81,6 +82,7 @@ defmodule Microwaveprop.Weather.IemRateLimiterTest do
describe "adaptive gap" do describe "adaptive gap" do
defp start_adaptive(base, max) do defp start_adaptive(base, max) do
# credo:disable-for-this-line Credo.Check.Warning.UnsafeToAtom
name = :"iem_rate_limiter_test_#{System.unique_integer([:positive])}" name = :"iem_rate_limiter_test_#{System.unique_integer([:positive])}"
start_supervised!({IemRateLimiter, interval_ms: base, max_interval_ms: max, name: name}) start_supervised!({IemRateLimiter, interval_ms: base, max_interval_ms: max, name: name})
name name
@ -120,6 +122,7 @@ defmodule Microwaveprop.Weather.IemRateLimiterTest do
describe "unregistered server paths" do describe "unregistered server paths" do
test "acquire/1 with a live PID succeeds via the is_pid clause" do test "acquire/1 with a live PID succeeds via the is_pid clause" do
# credo:disable-for-this-line Credo.Check.Warning.UnsafeToAtom
name = :"iem_rate_limiter_pid_test_#{System.unique_integer([:positive])}" name = :"iem_rate_limiter_pid_test_#{System.unique_integer([:positive])}"
pid = start_supervised!({IemRateLimiter, interval_ms: 0, name: name}) pid = start_supervised!({IemRateLimiter, interval_ms: 0, name: name})
assert IemRateLimiter.acquire(pid) == :ok assert IemRateLimiter.acquire(pid) == :ok

View file

@ -18,6 +18,7 @@ defmodule MicrowavepropWeb.Api.ErrorJSONTest do
field :age, :integer field :age, :integer
end end
@spec cs(map()) :: Changeset.t()
def cs(attrs) do def cs(attrs) do
%__MODULE__{} %__MODULE__{}
|> cast(attrs, [:name, :age]) |> cast(attrs, [:name, :age])

View file

@ -88,6 +88,7 @@ defmodule MicrowavepropWeb.TelemetryTest do
test "starts a named Supervisor under a fresh registry" do test "starts a named Supervisor under a fresh registry" do
# Different process name to avoid colliding with the already-running # Different process name to avoid colliding with the already-running
# application supervisor. # application supervisor.
# credo:disable-for-this-line Credo.Check.Warning.UnsafeToAtom
sup_name = :"telemetry_under_test_#{System.unique_integer([:positive])}" sup_name = :"telemetry_under_test_#{System.unique_integer([:positive])}"
{:ok, pid} = Supervisor.start_link(Telemetry, :ok, name: sup_name) {:ok, pid} = Supervisor.start_link(Telemetry, :ok, name: sup_name)

View file

@ -19,6 +19,7 @@ defmodule MicrowavepropWeb.ConnCase do
alias Microwaveprop.Accounts alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.Scope alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Accounts.User
alias Microwaveprop.AccountsFixtures alias Microwaveprop.AccountsFixtures
alias Microwaveprop.DataCase alias Microwaveprop.DataCase
alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Propagation.ScoreCache
@ -55,6 +56,7 @@ defmodule MicrowavepropWeb.ConnCase do
It stores an updated connection and a registered user in the It stores an updated connection and a registered user in the
test context. test context.
""" """
@spec register_and_log_in_user(map()) :: %{conn: Plug.Conn.t(), user: User.t(), scope: Scope.t()}
def register_and_log_in_user(%{conn: conn} = context) do def register_and_log_in_user(%{conn: conn} = context) do
user = AccountsFixtures.user_fixture() user = AccountsFixtures.user_fixture()
scope = Scope.for_user(user) scope = Scope.for_user(user)
@ -72,6 +74,7 @@ defmodule MicrowavepropWeb.ConnCase do
It returns an updated `conn`. It returns an updated `conn`.
""" """
@spec log_in_user(Plug.Conn.t(), User.t(), Keyword.t()) :: Plug.Conn.t()
def log_in_user(conn, user, opts \\ []) do def log_in_user(conn, user, opts \\ []) do
token = Accounts.generate_user_session_token(user) token = Accounts.generate_user_session_token(user)

View file

@ -42,6 +42,7 @@ defmodule Microwaveprop.DataCase do
crash for lack of a Req.Test plug. Individual tests can override with crash for lack of a Req.Test plug. Individual tests can override with
their own `Req.Test.stub/2`. their own `Req.Test.stub/2`.
""" """
@spec stub_nexrad_default :: :ok
def stub_nexrad_default do def stub_nexrad_default do
Req.Test.stub(Microwaveprop.Weather.NexradClient, fn conn -> Req.Test.stub(Microwaveprop.Weather.NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found") Plug.Conn.send_resp(conn, 404, "not found")
@ -51,6 +52,7 @@ defmodule Microwaveprop.DataCase do
@doc """ @doc """
Sets up the sandbox based on the test tags. Sets up the sandbox based on the test tags.
""" """
@spec setup_sandbox(map()) :: :ok
def setup_sandbox(tags) do def setup_sandbox(tags) do
pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: not tags[:async]) pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: not tags[:async])
on_exit(fn -> Sandbox.stop_owner(pid) end) on_exit(fn -> Sandbox.stop_owner(pid) end)
@ -60,6 +62,7 @@ defmodule Microwaveprop.DataCase do
Wipe the propagation ScoresFile tree between tests so files don't Wipe the propagation ScoresFile tree between tests so files don't
leak between cases sharing the same tmp dir. leak between cases sharing the same tmp dir.
""" """
@spec reset_score_files :: :ok
def reset_score_files do def reset_score_files do
dir = Application.get_env(:microwaveprop, :propagation_scores_dir) dir = Application.get_env(:microwaveprop, :propagation_scores_dir)
@ -76,6 +79,7 @@ defmodule Microwaveprop.DataCase do
assert %{password: ["password is too short"]} = errors_on(changeset) assert %{password: ["password is too short"]} = errors_on(changeset)
""" """
@spec errors_on(Ecto.Changeset.t()) :: map()
def errors_on(changeset) do def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key -> Regex.replace(~r"%{(\w+)}", message, fn _, key ->

View file

@ -11,10 +11,16 @@ defmodule Microwaveprop.AccountsFixtures do
alias Microwaveprop.Accounts.User alias Microwaveprop.Accounts.User
alias Microwaveprop.Repo alias Microwaveprop.Repo
@spec unique_user_email :: String.t()
def unique_user_email, do: "user#{System.unique_integer([:positive])}@example.com" def unique_user_email, do: "user#{System.unique_integer([:positive])}@example.com"
@spec unique_user_callsign :: String.t()
def unique_user_callsign, do: "W#{[:positive] |> System.unique_integer() |> rem(1_000_000)}X" def unique_user_callsign, do: "W#{[:positive] |> System.unique_integer() |> rem(1_000_000)}X"
@spec valid_user_password :: String.t()
def valid_user_password, do: "hello world!" def valid_user_password, do: "hello world!"
@spec valid_user_attributes(map()) :: map()
def valid_user_attributes(attrs \\ %{}) do def valid_user_attributes(attrs \\ %{}) do
password = valid_user_password() password = valid_user_password()
@ -27,6 +33,7 @@ defmodule Microwaveprop.AccountsFixtures do
}) })
end end
@spec unconfirmed_user_fixture(map()) :: User.t()
def unconfirmed_user_fixture(attrs \\ %{}) do def unconfirmed_user_fixture(attrs \\ %{}) do
{:ok, user} = {:ok, user} =
attrs attrs
@ -36,6 +43,7 @@ defmodule Microwaveprop.AccountsFixtures do
user user
end end
@spec user_fixture(map()) :: User.t()
def user_fixture(attrs \\ %{}) do def user_fixture(attrs \\ %{}) do
user = unconfirmed_user_fixture(attrs) user = unconfirmed_user_fixture(attrs)
@ -47,21 +55,25 @@ defmodule Microwaveprop.AccountsFixtures do
user user
end end
@spec user_scope_fixture() :: Scope.t()
def user_scope_fixture do def user_scope_fixture do
user = user_fixture() user = user_fixture()
user_scope_fixture(user) user_scope_fixture(user)
end end
@spec user_scope_fixture(User.t()) :: Scope.t()
def user_scope_fixture(user) do def user_scope_fixture(user) do
Scope.for_user(user) Scope.for_user(user)
end end
@spec extract_user_token(fun :: function()) :: String.t()
def extract_user_token(fun) do def extract_user_token(fun) do
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
[_, token | _] = String.split(captured_email.text_body, "[TOKEN]") [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
token token
end end
@spec override_token_authenticated_at(String.t(), DateTime.t()) :: {non_neg_integer(), nil | [term()]}
def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
Repo.update_all( Repo.update_all(
from(t in Accounts.UserToken, from(t in Accounts.UserToken,
@ -71,6 +83,7 @@ defmodule Microwaveprop.AccountsFixtures do
) )
end end
@spec offset_user_token(String.t(), integer(), :second | :minute | :hour | :day) :: {non_neg_integer(), nil | [term()]}
def offset_user_token(token, amount_to_add, unit) do def offset_user_token(token, amount_to_add, unit) do
dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit) dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)

View file

@ -5,6 +5,7 @@ defmodule Microwaveprop.BeaconsFixtures do
alias Microwaveprop.Beacons alias Microwaveprop.Beacons
@spec valid_beacon_attrs(map()) :: map()
def valid_beacon_attrs(attrs \\ %{}) do def valid_beacon_attrs(attrs \\ %{}) do
Enum.into(attrs, %{ Enum.into(attrs, %{
frequency_mhz: 10_368.1, frequency_mhz: 10_368.1,
@ -16,6 +17,7 @@ defmodule Microwaveprop.BeaconsFixtures do
}) })
end end
@spec beacon_fixture(Microwaveprop.Accounts.User.t(), map()) :: Microwaveprop.Beacons.Beacon.t()
def beacon_fixture(user, attrs \\ %{}) do def beacon_fixture(user, attrs \\ %{}) do
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(attrs)) {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(attrs))
beacon beacon