From 495b1a75b2340fb46f7027c45736527b23d38e3e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 10 Mar 2026 16:02:49 -0500 Subject: [PATCH] Add GeoIP import HTTP tests --- lib/mix/tasks/geoip.import.ex | 26 +++--- lib/towerops/accounts/hibp.ex | 4 +- lib/towerops/agents/release_checker.ex | 22 ++++- lib/towerops/billing/stripe_client.ex | 52 ++++++----- lib/towerops/geocoding.ex | 4 +- lib/towerops/http.ex | 44 +++++++++ .../monitoring/executors/http_executor.ex | 4 +- lib/towerops/netbox/client.ex | 6 +- lib/towerops/pagerduty/client.ex | 4 +- lib/towerops/security/cloudflare_client.ex | 8 +- lib/towerops/weather/client.ex | 4 +- .../firmware_version_fetcher_worker.ex | 3 +- lib/towerops_web/live/agent_live/show.ex | 2 +- lib/towerops_web/live/help_live/index.ex | 8 +- test/towerops/agents/release_checker_test.exs | 90 ++++++++++++++++--- test/towerops/http_test.exs | 30 +++++++ .../maintenance/maintenance_window_test.exs | 80 +++++++++++++++++ test/towerops/snmp/batch_insert_test.exs | 2 +- .../components/consent_prompt_test.exs | 53 +++++++++++ test/towerops_web/telemetry_filter_test.exs | 42 +++++++++ 20 files changed, 424 insertions(+), 64 deletions(-) create mode 100644 lib/towerops/http.ex create mode 100644 test/towerops/http_test.exs create mode 100644 test/towerops/maintenance/maintenance_window_test.exs create mode 100644 test/towerops_web/components/consent_prompt_test.exs create mode 100644 test/towerops_web/telemetry_filter_test.exs diff --git a/lib/mix/tasks/geoip.import.ex b/lib/mix/tasks/geoip.import.ex index d7ce1b4a..cb99ab6f 100644 --- a/lib/mix/tasks/geoip.import.ex +++ b/lib/mix/tasks/geoip.import.ex @@ -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 diff --git a/lib/towerops/accounts/hibp.ex b/lib/towerops/accounts/hibp.ex index bc5be124..e38a0e3c 100644 --- a/lib/towerops/accounts/hibp.ex +++ b/lib/towerops/accounts/hibp.ex @@ -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} diff --git a/lib/towerops/agents/release_checker.ex b/lib/towerops/agents/release_checker.ex index 3e438283..0833e3c3 100644 --- a/lib/towerops/agents/release_checker.ex +++ b/lib/towerops/agents/release_checker.ex @@ -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 diff --git a/lib/towerops/billing/stripe_client.ex b/lib/towerops/billing/stripe_client.ex index f29351e3..49a5b39e 100644 --- a/lib/towerops/billing/stripe_client.ex +++ b/lib/towerops/billing/stripe_client.ex @@ -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 diff --git a/lib/towerops/geocoding.ex b/lib/towerops/geocoding.ex index 79e667f6..2d12b87a 100644 --- a/lib/towerops/geocoding.ex +++ b/lib/towerops/geocoding.ex @@ -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} diff --git a/lib/towerops/http.ex b/lib/towerops/http.ex new file mode 100644 index 00000000..a54d06b7 --- /dev/null +++ b/lib/towerops/http.ex @@ -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 diff --git a/lib/towerops/monitoring/executors/http_executor.ex b/lib/towerops/monitoring/executors/http_executor.ex index c19aac69..f45d6782 100644 --- a/lib/towerops/monitoring/executors/http_executor.ex +++ b/lib/towerops/monitoring/executors/http_executor.ex @@ -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) diff --git a/lib/towerops/netbox/client.ex b/lib/towerops/netbox/client.ex index 04dbeb64..dd672a30 100644 --- a/lib/towerops/netbox/client.ex +++ b/lib/towerops/netbox/client.ex @@ -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], diff --git a/lib/towerops/pagerduty/client.ex b/lib/towerops/pagerduty/client.ex index 42e7564a..07b635a9 100644 --- a/lib/towerops/pagerduty/client.ex +++ b/lib/towerops/pagerduty/client.ex @@ -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} diff --git a/lib/towerops/security/cloudflare_client.ex b/lib/towerops/security/cloudflare_client.ex index 8ed2d15e..84569ef5 100644 --- a/lib/towerops/security/cloudflare_client.ex +++ b/lib/towerops/security/cloudflare_client.ex @@ -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}"} ], diff --git a/lib/towerops/weather/client.ex b/lib/towerops/weather/client.ex index deb4b8dd..a4939c50 100644 --- a/lib/towerops/weather/client.ex +++ b/lib/towerops/weather/client.ex @@ -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} diff --git a/lib/towerops/workers/firmware_version_fetcher_worker.ex b/lib/towerops/workers/firmware_version_fetcher_worker.ex index 37ea048f..6eb3a8a9 100644 --- a/lib/towerops/workers/firmware_version_fetcher_worker.ex +++ b/lib/towerops/workers/firmware_version_fetcher_worker.ex @@ -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} diff --git a/lib/towerops_web/live/agent_live/show.ex b/lib/towerops_web/live/agent_live/show.ex index 3f5b7a22..dfb570af 100644 --- a/lib/towerops_web/live/agent_live/show.ex +++ b/lib/towerops_web/live/agent_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex index 2f9aab97..91ef47cc 100644 --- a/lib/towerops_web/live/help_live/index.ex +++ b/lib/towerops_web/live/help_live/index.ex @@ -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), diff --git a/test/towerops/agents/release_checker_test.exs b/test/towerops/agents/release_checker_test.exs index 51090717..ef7e3ef7 100644 --- a/test/towerops/agents/release_checker_test.exs +++ b/test/towerops/agents/release_checker_test.exs @@ -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 diff --git a/test/towerops/http_test.exs b/test/towerops/http_test.exs new file mode 100644 index 00000000..bd30b9e2 --- /dev/null +++ b/test/towerops/http_test.exs @@ -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 diff --git a/test/towerops/maintenance/maintenance_window_test.exs b/test/towerops/maintenance/maintenance_window_test.exs new file mode 100644 index 00000000..0fde30e2 --- /dev/null +++ b/test/towerops/maintenance/maintenance_window_test.exs @@ -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 diff --git a/test/towerops/snmp/batch_insert_test.exs b/test/towerops/snmp/batch_insert_test.exs index 335d5e3b..7035f9d6 100644 --- a/test/towerops/snmp/batch_insert_test.exs +++ b/test/towerops/snmp/batch_insert_test.exs @@ -1,5 +1,5 @@ defmodule Towerops.Snmp.BatchInsertTest do - use Towerops.DataCase, async: true + use Towerops.DataCase, async: false import Towerops.AccountsFixtures diff --git a/test/towerops_web/components/consent_prompt_test.exs b/test/towerops_web/components/consent_prompt_test.exs new file mode 100644 index 00000000..49710485 --- /dev/null +++ b/test/towerops_web/components/consent_prompt_test.exs @@ -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""" + + """) + + 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""" + + """) + + 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""" + + """) + + assert html =~ ~s(id="consent-custom_policy") + assert html =~ ~s(href="/") + assert html =~ "Policy" + end + end +end diff --git a/test/towerops_web/telemetry_filter_test.exs b/test/towerops_web/telemetry_filter_test.exs new file mode 100644 index 00000000..fa58bc91 --- /dev/null +++ b/test/towerops_web/telemetry_filter_test.exs @@ -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