Add GeoIP import HTTP tests

This commit is contained in:
Graham McIntire 2026-03-10 16:02:49 -05:00
parent 9d513b77ee
commit 495b1a75b2
No known key found for this signature in database
20 changed files with 424 additions and 64 deletions

View file

@ -42,6 +42,7 @@ defmodule Mix.Tasks.Geoip.Import do
alias Towerops.GeoIP.Block
alias Towerops.GeoIP.Location
alias Towerops.HTTP
alias Towerops.Repo
# PostgreSQL has a 65,535 parameter limit
@ -202,19 +203,22 @@ defmodule Mix.Tasks.Geoip.Import do
"truncate" => truncate
}
response =
Req.post!(url,
json: body,
headers: [{"authorization", "Bearer #{api_key}"}],
retry: :transient
)
case HTTP.post(__MODULE__, url,
json: body,
headers: [{"authorization", "Bearer #{api_key}"}],
retry: :transient
) do
{:ok, %{status: 200, body: response_body}} ->
response_body
if response.status != 200 do
Mix.shell().error("API request failed: #{inspect(response.body)}")
raise "Failed to send batch to API"
{:ok, %{body: response_body}} ->
Mix.shell().error("API request failed: #{inspect(response_body)}")
raise "Failed to send batch to API"
{:error, reason} ->
Mix.shell().error("API request failed: #{inspect(reason)}")
raise "Failed to send batch to API"
end
response.body
end
defp import_from_directory(directory) do

View file

@ -9,6 +9,8 @@ defmodule Towerops.Accounts.HIBP do
See: https://haveibeenpwned.com/API/v3#PwnedPasswords
"""
alias Towerops.HTTP
require Logger
@api_url "https://api.pwnedpasswords.com/range/"
@ -59,7 +61,7 @@ defmodule Towerops.Accounts.HIBP do
defp fetch_breaches(prefix) do
url = @api_url <> prefix
case Req.get(url, receive_timeout: @timeout) do
case HTTP.get(__MODULE__, url, receive_timeout: @timeout) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}

View file

@ -78,7 +78,7 @@ defmodule Towerops.Agents.ReleaseChecker do
defp fetch_and_cache do
url = "https://api.github.com/repos/#{@github_repo}/releases/latest"
case Req.get(url, headers: [{"accept", "application/vnd.github+json"}]) do
case req_get(url, headers: [{"accept", "application/vnd.github+json"}]) do
{:ok, %Req.Response{status: 200, body: body}} ->
release = parse_release(body)
:persistent_term.put({__MODULE__, :release}, {release, System.monotonic_time(:millisecond)})
@ -120,7 +120,7 @@ defmodule Towerops.Agents.ReleaseChecker do
sha_asset = Enum.find(all_assets, fn a -> a["name"] == "#{binary_name}.sha256" end)
if sha_asset do
case Req.get(sha_asset["browser_download_url"]) do
case req_get(sha_asset["browser_download_url"]) do
{:ok, %Req.Response{status: 200, body: body}} ->
body |> String.trim() |> String.split(" ") |> List.first()
@ -129,4 +129,22 @@ defmodule Towerops.Agents.ReleaseChecker do
end
end
end
defp req_get(url, opts \\ []) do
opts =
if Application.get_env(:towerops, :env) == :test do
Keyword.put(opts, :plug, {Req.Test, __MODULE__})
else
opts
end
Req.get(url, opts)
rescue
error in RuntimeError ->
if String.contains?(Exception.message(error), "cannot find mock/stub") do
{:error, :no_test_stub}
else
reraise error, __STACKTRACE__
end
end
end

View file

@ -136,31 +136,25 @@ defmodule Towerops.Billing.StripeClient do
Returns {:ok, price_object} or {:error, reason}.
"""
def create_price(unit_amount_dollars) when is_binary(unit_amount_dollars) do
# Convert dollars to cents (Stripe expects cents)
# "2.00" -> Decimal.new("2.00") -> Decimal.mult(100) -> "200.00"
unit_amount_cents =
unit_amount_dollars
|> Decimal.new()
|> Decimal.mult(Decimal.new("100"))
|> Decimal.to_string()
params = %{
product: stripe_product_id(),
currency: "usd",
unit_amount_decimal: unit_amount_cents,
recurring: %{
interval: "month",
usage_type: "metered",
aggregate_usage: "max"
},
billing_scheme: "per_unit",
metadata: %{
created_by: "towerops_admin_ui",
created_at: DateTime.to_iso8601(DateTime.utc_now())
with {:ok, unit_amount_cents} <- dollars_to_cents(unit_amount_dollars) do
params = %{
product: stripe_product_id(),
currency: "usd",
unit_amount_decimal: unit_amount_cents,
recurring: %{
interval: "month",
usage_type: "metered",
aggregate_usage: "max"
},
billing_scheme: "per_unit",
metadata: %{
created_by: "towerops_admin_ui",
created_at: DateTime.to_iso8601(DateTime.utc_now())
}
}
}
post("/v1/prices", params)
post("/v1/prices", params)
end
end
@doc """
@ -193,6 +187,18 @@ defmodule Towerops.Billing.StripeClient do
request(:post, path, body: encoded)
end
defp dollars_to_cents(unit_amount_dollars) do
unit_amount_cents =
unit_amount_dollars
|> Decimal.new()
|> Decimal.mult(Decimal.new("100"))
|> Decimal.to_string()
{:ok, unit_amount_cents}
rescue
Decimal.Error -> {:error, {:bad_request, "Invalid amount"}}
end
defp request(method, path, opts) do
url = @base_url <> path

View file

@ -5,6 +5,8 @@ defmodule Towerops.Geocoding do
Supports both system-wide API keys and organization-specific encrypted API keys.
"""
alias Towerops.HTTP
require Logger
@google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json"
@ -49,7 +51,7 @@ defmodule Towerops.Geocoding do
"key" => api_key
}
case Req.get(@google_geocoding_url, params: params, timeout: 10_000) do
case HTTP.get(__MODULE__, @google_geocoding_url, params: params, timeout: 10_000) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}

44
lib/towerops/http.ex Normal file
View file

@ -0,0 +1,44 @@
defmodule Towerops.HTTP do
@moduledoc false
def request(owner, req_opts) when is_list(req_opts) do
req_opts
|> maybe_attach_test_plug(owner)
|> Req.request()
rescue
error in RuntimeError ->
if String.contains?(Exception.message(error), "cannot find mock/stub") do
{:error, :no_test_stub}
else
reraise error, __STACKTRACE__
end
end
def get(owner, url_or_opts, opts \\ []) do
simple_request(:get, owner, url_or_opts, opts)
end
def post(owner, url_or_opts, opts \\ []) do
simple_request(:post, owner, url_or_opts, opts)
end
def delete(owner, url_or_opts, opts \\ []) do
simple_request(:delete, owner, url_or_opts, opts)
end
defp simple_request(method, owner, url, opts) when is_binary(url) do
request(owner, [method: method, url: url] ++ opts)
end
defp simple_request(method, owner, req_opts, []) when is_list(req_opts) do
request(owner, Keyword.put_new(req_opts, :method, method))
end
defp maybe_attach_test_plug(req_opts, owner) do
if Application.get_env(:towerops, :env) == :test and !Keyword.has_key?(req_opts, :plug) do
Keyword.put(req_opts, :plug, {Req.Test, owner})
else
req_opts
end
end
end

View file

@ -15,6 +15,8 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
}
"""
alias Towerops.HTTP
require Logger
@default_timeout 5000
@ -30,7 +32,7 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
start_time = System.monotonic_time(:millisecond)
req_opts = build_req_opts(config, timeout_ms)
case Req.request(req_opts) do
case HTTP.request(__MODULE__, req_opts) do
{:ok, response} ->
response_time = System.monotonic_time(:millisecond) - start_time
validate_response(response, config, response_time)

View file

@ -6,6 +6,8 @@ defmodule Towerops.NetBox.Client do
against a user-provided NetBox instance.
"""
alias Towerops.HTTP
require Logger
@doc """
@ -82,7 +84,7 @@ defmodule Towerops.NetBox.Client do
with {:ok, url} <- normalize_url(base_url) do
full_url = url <> path
case Req.get(full_url,
case HTTP.get(__MODULE__, full_url,
headers: auth_headers(token),
params: params,
connect_options: [timeout: 10_000],
@ -116,7 +118,7 @@ defmodule Towerops.NetBox.Client do
with {:ok, url} <- normalize_url(base_url) do
full_url = url <> path
case Req.post(full_url,
case HTTP.post(__MODULE__, full_url,
headers: auth_headers(token),
json: body,
connect_options: [timeout: 10_000],

View file

@ -1,6 +1,8 @@
defmodule Towerops.PagerDuty.Client do
@moduledoc "PagerDuty Events API v2 client."
alias Towerops.HTTP
require Logger
@events_url "https://events.pagerduty.com/v2/enqueue"
@ -75,7 +77,7 @@ defmodule Towerops.PagerDuty.Client do
end
defp send_event(body) do
case Req.post(@events_url, json: body) do
case HTTP.post(__MODULE__, @events_url, json: body) do
{:ok, %{status: status, body: resp_body}} when status in [200, 202] ->
{:ok, resp_body}

View file

@ -6,6 +6,8 @@ defmodule Towerops.Security.CloudflareClient do
pre-application blocking of brute force scanners.
"""
alias Towerops.HTTP
require Logger
@base_url "https://api.cloudflare.com/client/v4"
@ -30,7 +32,7 @@ defmodule Towerops.Security.CloudflareClient do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
case Req.post(url,
case HTTP.post(__MODULE__, url,
json: body,
headers: [
{"authorization", "Bearer #{api_token}"},
@ -67,7 +69,7 @@ defmodule Towerops.Security.CloudflareClient do
{:ok, rule_id} <- find_rule_by_ip(zone_id, api_token, ip_address) do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules/#{rule_id}"
case Req.delete(url,
case HTTP.delete(__MODULE__, url,
headers: [
{"authorization", "Bearer #{api_token}"}
]
@ -100,7 +102,7 @@ defmodule Towerops.Security.CloudflareClient do
defp find_rule_by_ip(zone_id, api_token, ip_address) do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
case Req.get(url,
case HTTP.get(__MODULE__, url,
headers: [
{"authorization", "Bearer #{api_token}"}
],

View file

@ -6,6 +6,8 @@ defmodule Towerops.Weather.Client do
Fetches weather by latitude/longitude perfect for tower site locations.
"""
alias Towerops.HTTP
require Logger
@base_url "https://api.openweathermap.org/data/2.5"
@ -25,7 +27,7 @@ defmodule Towerops.Weather.Client do
req_opts = maybe_add_test_plug([url: url, params: [lat: lat, lon: lon, appid: api_key]], opts)
case Req.get(req_opts) do
case HTTP.get(__MODULE__, req_opts) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}

View file

@ -10,6 +10,7 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
import SweetXml
alias Towerops.Devices.Firmware
alias Towerops.HTTP
require Logger
@ -44,7 +45,7 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
Returns `{:ok, body}` on success or `{:error, reason}` on failure.
"""
def fetch_rss_feed do
case Req.get(@rss_url, receive_timeout: 10_000) do
case HTTP.get(__MODULE__, @rss_url, receive_timeout: 10_000) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}

View file

@ -79,7 +79,7 @@ defmodule ToweropsWeb.AgentLive.Show do
put_flash(socket, :error, t("No binary available for architecture: %{arch}", arch: arch))
{:error, _reason} ->
put_flash(socket, :error, t("Failed to fetch latest release from GitHub"))
put_flash(socket, :error, t("Failed to fetch latest release"))
end
end

View file

@ -4,6 +4,7 @@ defmodule ToweropsWeb.HelpLive.Index do
alias Towerops.Accounts
alias Towerops.Accounts.Scope
alias Towerops.HTTP
alias Towerops.Organizations
@impl true
@ -90,7 +91,7 @@ defmodule ToweropsWeb.HelpLive.Index do
url =
"https://www.random.org/strings/?num=1&len=24&digits=on&upperalpha=on&loweralpha=on&unique=on&format=plain&rnd=new"
case Req.get(url) do
case HTTP.get(__MODULE__, url) do
{:ok, %{status: 200, body: body}} ->
password = String.trim(body)
{:ok, password}
@ -99,10 +100,13 @@ defmodule ToweropsWeb.HelpLive.Index do
{:error, "random.org returned status #{status}"}
{:error, error} ->
{:error, Exception.message(error)}
{:error, format_request_error(error)}
end
end
defp format_request_error(%_{} = error), do: Exception.message(error)
defp format_request_error(error), do: inspect(error)
defp get_current_user(socket, session) do
# Try to get current_user from socket assigns (if already set by on_mount)
with nil <- Map.get(socket.assigns, :current_user),

View file

@ -1,23 +1,87 @@
defmodule Towerops.Agents.ReleaseCheckerTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false
alias Towerops.Agents.ReleaseChecker
setup do
ReleaseChecker.invalidate_cache()
on_exit(fn ->
ReleaseChecker.invalidate_cache()
end)
end
describe "get_latest_release/0" do
@tag :integration
test "returns release info with version and assets" do
test "returns release info from the configured test stub" do
Req.Test.stub(ReleaseChecker, fn conn ->
if conn.request_path == "/repos/towerops-app/towerops-agent/releases/latest" do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"tag_name" => "v1.2.3",
"assets" => [
%{
"name" => "towerops-agent-linux-amd64",
"browser_download_url" => "https://example.com/amd64"
},
%{
"name" => "towerops-agent-linux-amd64.sha256",
"browser_download_url" => "https://example.com/amd64.sha256"
},
%{
"name" => "towerops-agent-linux-arm64",
"browser_download_url" => "https://example.com/arm64"
}
]
})
)
else
conn
|> Plug.Conn.put_resp_content_type("text/plain")
|> Plug.Conn.send_resp(200, "abc123 towerops-agent-linux-amd64")
end
end)
assert {:ok, release} = ReleaseChecker.get_latest_release()
assert release.version == "1.2.3"
assert Enum.any?(release.assets, &(&1.name == "towerops-agent-linux-amd64"))
assert Enum.any?(release.assets, &(&1.checksum == "abc123"))
end
test "returns error when the API is unavailable" do
Req.Test.stub(ReleaseChecker, fn conn ->
Plug.Conn.send_resp(conn, 503, "")
end)
assert {:error, {:github_api, 503}} = ReleaseChecker.get_latest_release()
end
test "uses cached release info until invalidated" do
parent = self()
Req.Test.stub(ReleaseChecker, fn conn ->
send(parent, {:release_request, conn.request_path})
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"tag_name" => "v9.9.9",
"assets" => []
})
)
end)
{:ok, release} = ReleaseChecker.get_latest_release()
assert {:ok, cached_release} = ReleaseChecker.get_latest_release()
assert is_binary(release.version)
assert is_list(release.assets)
assert Enum.any?(release.assets, fn asset ->
String.contains?(asset.name, "amd64")
end)
assert Enum.any?(release.assets, fn asset ->
String.contains?(asset.name, "arm64")
end)
assert release.version == "9.9.9"
assert cached_release == release
assert_receive {:release_request, "/repos/towerops-app/towerops-agent/releases/latest"}
refute_receive {:release_request, "/repos/towerops-app/towerops-agent/releases/latest"}
end
end

View file

@ -0,0 +1,30 @@
defmodule Towerops.HTTPTest do
use ExUnit.Case, async: true
alias Towerops.HTTP
alias Towerops.HTTPTest.ExplicitStub
test "returns an error when no test stub is configured" do
assert {:error, :no_test_stub} = HTTP.get(__MODULE__, "https://example.com")
end
test "uses Req.Test stubs in test mode" do
Req.Test.stub(__MODULE__, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{"ok" => true}))
end)
assert {:ok, %Req.Response{status: 200, body: %{"ok" => true}}} =
HTTP.get(__MODULE__, "https://example.com")
end
test "respects an explicit plug override" do
Req.Test.stub(ExplicitStub, fn conn ->
Plug.Conn.send_resp(conn, 204, "")
end)
assert {:ok, %Req.Response{status: 204}} =
HTTP.get(__MODULE__, "https://example.com", plug: {Req.Test, ExplicitStub})
end
end

View file

@ -0,0 +1,80 @@
defmodule Towerops.Maintenance.MaintenanceWindowTest do
use ExUnit.Case, async: true
alias Towerops.Maintenance.MaintenanceWindow
describe "changeset/2" do
test "is valid with required attributes" do
changeset = MaintenanceWindow.changeset(%MaintenanceWindow{}, valid_attrs())
assert changeset.valid?
end
test "requires ends_at to be after starts_at" do
attrs =
valid_attrs(%{
ends_at: ~U[2026-03-10 10:00:00Z]
})
changeset = MaintenanceWindow.changeset(%MaintenanceWindow{}, attrs)
refute changeset.valid?
assert "must be after starts_at" in errors_on(changeset).ends_at
end
test "validates name length" do
changeset =
MaintenanceWindow.changeset(
%MaintenanceWindow{},
valid_attrs(%{name: String.duplicate("a", 256)})
)
refute changeset.valid?
assert "should be at most 255 character(s)" in errors_on(changeset).name
end
test "validates reason length" do
changeset =
MaintenanceWindow.changeset(
%MaintenanceWindow{},
valid_attrs(%{reason: String.duplicate("a", 1001)})
)
refute changeset.valid?
assert "should be at most 1000 character(s)" in errors_on(changeset).reason
end
test "allows ends_at to be absent while other required fields are present" do
attrs = Map.delete(valid_attrs(), :ends_at)
changeset = MaintenanceWindow.changeset(%MaintenanceWindow{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).ends_at
end
end
defp valid_attrs(overrides \\ %{}) do
Map.merge(
%{
name: "Fiber maintenance",
reason: "Planned upgrade",
starts_at: ~U[2026-03-10 10:00:00Z],
ends_at: ~U[2026-03-10 11:00:00Z],
organization_id: Ecto.UUID.generate(),
created_by_id: Ecto.UUID.generate(),
suppress_alerts: true,
recurring: false
},
overrides
)
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.fetch!(String.to_existing_atom(key)) |> to_string()
end)
end)
end
end

View file

@ -1,5 +1,5 @@
defmodule Towerops.Snmp.BatchInsertTest do
use Towerops.DataCase, async: true
use Towerops.DataCase, async: false
import Towerops.AccountsFixtures

View file

@ -0,0 +1,53 @@
defmodule ToweropsWeb.Components.ConsentPromptTest do
use ExUnit.Case, async: true
import Phoenix.Component
import Phoenix.LiveViewTest
alias ToweropsWeb.Components.ConsentPrompt
describe "consent_modal/1" do
test "renders nothing when there are no policies" do
assigns = %{user_id: "user-1", policies: []}
html =
rendered_to_string(~H"""
<ConsentPrompt.consent_modal user_id={@user_id} policies={@policies} />
""")
assert html == ""
end
test "renders translated consent checkboxes and links for known policies" do
assigns = %{user_id: "user-1", policies: ["privacy_policy", "terms_of_service"]}
html =
rendered_to_string(~H"""
<ConsentPrompt.consent_modal user_id={@user_id} policies={@policies} />
""")
assert html =~ ~s(id="consent-modal")
assert html =~ ~s(phx-value-user-id="user-1")
assert html =~ ~s(id="consent-privacy_policy")
assert html =~ ~s(href="/privacy")
assert html =~ "Privacy Policy"
assert html =~ ~s(id="consent-terms_of_service")
assert html =~ ~s(href="/terms")
assert html =~ "Terms of Service"
assert html =~ "Accept and Continue"
end
test "falls back to the generic policy label and root link for unknown policies" do
assigns = %{user_id: "user-1", policies: ["custom_policy"]}
html =
rendered_to_string(~H"""
<ConsentPrompt.consent_modal user_id={@user_id} policies={@policies} />
""")
assert html =~ ~s(id="consent-custom_policy")
assert html =~ ~s(href="/")
assert html =~ "Policy"
end
end
end

View file

@ -0,0 +1,42 @@
defmodule ToweropsWeb.TelemetryFilterTest do
use ExUnit.Case, async: true
alias ToweropsWeb.TelemetryFilter
require Logger
describe "filter_health_checks/2" do
test "stops logs for health check request paths" do
log_event = {:info, self(), {Logger, "request completed", {{2026, 3, 10}, {12, 0, 0, 0}}, request_path: "/health"}}
assert :stop = TelemetryFilter.filter_health_checks(log_event, [])
end
test "stops logs when phoenix logging is disabled" do
log_event = {:info, self(), {Logger, "request completed", {{2026, 3, 10}, {12, 0, 0, 0}}, phoenix_log: false}}
assert :stop = TelemetryFilter.filter_health_checks(log_event, [])
end
test "stops logs for matching message text" do
log_event = {:info, self(), {Logger, "HEAD / responded 200", {{2026, 3, 10}, {12, 0, 0, 0}}, []}}
assert :stop = TelemetryFilter.filter_health_checks(log_event, [])
end
test "ignores unrelated log events" do
log_event =
{:info, self(), {Logger, "GET /devices responded 200", {{2026, 3, 10}, {12, 0, 0, 0}}, request_path: "/devices"}}
assert :ignore = TelemetryFilter.filter_health_checks(log_event, [])
end
test "ignores unexpected log event shapes" do
assert :ignore = TelemetryFilter.filter_health_checks(:unexpected, [])
end
end
test "attach/0 returns ok" do
assert :ok = TelemetryFilter.attach()
end
end