Add GeoIP import HTTP tests
This commit is contained in:
parent
9d513b77ee
commit
495b1a75b2
20 changed files with 424 additions and 64 deletions
|
|
@ -42,6 +42,7 @@ defmodule Mix.Tasks.Geoip.Import do
|
||||||
|
|
||||||
alias Towerops.GeoIP.Block
|
alias Towerops.GeoIP.Block
|
||||||
alias Towerops.GeoIP.Location
|
alias Towerops.GeoIP.Location
|
||||||
|
alias Towerops.HTTP
|
||||||
alias Towerops.Repo
|
alias Towerops.Repo
|
||||||
|
|
||||||
# PostgreSQL has a 65,535 parameter limit
|
# PostgreSQL has a 65,535 parameter limit
|
||||||
|
|
@ -202,19 +203,22 @@ defmodule Mix.Tasks.Geoip.Import do
|
||||||
"truncate" => truncate
|
"truncate" => truncate
|
||||||
}
|
}
|
||||||
|
|
||||||
response =
|
case HTTP.post(__MODULE__, url,
|
||||||
Req.post!(url,
|
|
||||||
json: body,
|
json: body,
|
||||||
headers: [{"authorization", "Bearer #{api_key}"}],
|
headers: [{"authorization", "Bearer #{api_key}"}],
|
||||||
retry: :transient
|
retry: :transient
|
||||||
)
|
) do
|
||||||
|
{:ok, %{status: 200, body: response_body}} ->
|
||||||
|
response_body
|
||||||
|
|
||||||
if response.status != 200 do
|
{:ok, %{body: response_body}} ->
|
||||||
Mix.shell().error("API request failed: #{inspect(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"
|
raise "Failed to send batch to API"
|
||||||
end
|
end
|
||||||
|
|
||||||
response.body
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp import_from_directory(directory) do
|
defp import_from_directory(directory) do
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ defmodule Towerops.Accounts.HIBP do
|
||||||
See: https://haveibeenpwned.com/API/v3#PwnedPasswords
|
See: https://haveibeenpwned.com/API/v3#PwnedPasswords
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@api_url "https://api.pwnedpasswords.com/range/"
|
@api_url "https://api.pwnedpasswords.com/range/"
|
||||||
|
|
@ -59,7 +61,7 @@ defmodule Towerops.Accounts.HIBP do
|
||||||
defp fetch_breaches(prefix) do
|
defp fetch_breaches(prefix) do
|
||||||
url = @api_url <> prefix
|
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, %Req.Response{status: 200, body: body}} ->
|
||||||
{:ok, body}
|
{:ok, body}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ defmodule Towerops.Agents.ReleaseChecker do
|
||||||
defp fetch_and_cache do
|
defp fetch_and_cache do
|
||||||
url = "https://api.github.com/repos/#{@github_repo}/releases/latest"
|
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}} ->
|
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||||
release = parse_release(body)
|
release = parse_release(body)
|
||||||
:persistent_term.put({__MODULE__, :release}, {release, System.monotonic_time(:millisecond)})
|
: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)
|
sha_asset = Enum.find(all_assets, fn a -> a["name"] == "#{binary_name}.sha256" end)
|
||||||
|
|
||||||
if sha_asset do
|
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}} ->
|
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||||
body |> String.trim() |> String.split(" ") |> List.first()
|
body |> String.trim() |> String.split(" ") |> List.first()
|
||||||
|
|
||||||
|
|
@ -129,4 +129,22 @@ defmodule Towerops.Agents.ReleaseChecker do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -136,14 +136,7 @@ defmodule Towerops.Billing.StripeClient do
|
||||||
Returns {:ok, price_object} or {:error, reason}.
|
Returns {:ok, price_object} or {:error, reason}.
|
||||||
"""
|
"""
|
||||||
def create_price(unit_amount_dollars) when is_binary(unit_amount_dollars) do
|
def create_price(unit_amount_dollars) when is_binary(unit_amount_dollars) do
|
||||||
# Convert dollars to cents (Stripe expects cents)
|
with {:ok, unit_amount_cents} <- dollars_to_cents(unit_amount_dollars) do
|
||||||
# "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 = %{
|
params = %{
|
||||||
product: stripe_product_id(),
|
product: stripe_product_id(),
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
|
|
@ -162,6 +155,7 @@ defmodule Towerops.Billing.StripeClient do
|
||||||
|
|
||||||
post("/v1/prices", params)
|
post("/v1/prices", params)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Update subscription to use new price.
|
Update subscription to use new price.
|
||||||
|
|
@ -193,6 +187,18 @@ defmodule Towerops.Billing.StripeClient do
|
||||||
request(:post, path, body: encoded)
|
request(:post, path, body: encoded)
|
||||||
end
|
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
|
defp request(method, path, opts) do
|
||||||
url = @base_url <> path
|
url = @base_url <> path
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ defmodule Towerops.Geocoding do
|
||||||
Supports both system-wide API keys and organization-specific encrypted API keys.
|
Supports both system-wide API keys and organization-specific encrypted API keys.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json"
|
@google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json"
|
||||||
|
|
@ -49,7 +51,7 @@ defmodule Towerops.Geocoding do
|
||||||
"key" => api_key
|
"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, %Req.Response{status: 200, body: body}} ->
|
||||||
{:ok, body}
|
{:ok, body}
|
||||||
|
|
||||||
|
|
|
||||||
44
lib/towerops/http.ex
Normal file
44
lib/towerops/http.ex
Normal 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
|
||||||
|
|
@ -15,6 +15,8 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@default_timeout 5000
|
@default_timeout 5000
|
||||||
|
|
@ -30,7 +32,7 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
|
||||||
start_time = System.monotonic_time(:millisecond)
|
start_time = System.monotonic_time(:millisecond)
|
||||||
req_opts = build_req_opts(config, timeout_ms)
|
req_opts = build_req_opts(config, timeout_ms)
|
||||||
|
|
||||||
case Req.request(req_opts) do
|
case HTTP.request(__MODULE__, req_opts) do
|
||||||
{:ok, response} ->
|
{:ok, response} ->
|
||||||
response_time = System.monotonic_time(:millisecond) - start_time
|
response_time = System.monotonic_time(:millisecond) - start_time
|
||||||
validate_response(response, config, response_time)
|
validate_response(response, config, response_time)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule Towerops.NetBox.Client do
|
||||||
against a user-provided NetBox instance.
|
against a user-provided NetBox instance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -82,7 +84,7 @@ defmodule Towerops.NetBox.Client do
|
||||||
with {:ok, url} <- normalize_url(base_url) do
|
with {:ok, url} <- normalize_url(base_url) do
|
||||||
full_url = url <> path
|
full_url = url <> path
|
||||||
|
|
||||||
case Req.get(full_url,
|
case HTTP.get(__MODULE__, full_url,
|
||||||
headers: auth_headers(token),
|
headers: auth_headers(token),
|
||||||
params: params,
|
params: params,
|
||||||
connect_options: [timeout: 10_000],
|
connect_options: [timeout: 10_000],
|
||||||
|
|
@ -116,7 +118,7 @@ defmodule Towerops.NetBox.Client do
|
||||||
with {:ok, url} <- normalize_url(base_url) do
|
with {:ok, url} <- normalize_url(base_url) do
|
||||||
full_url = url <> path
|
full_url = url <> path
|
||||||
|
|
||||||
case Req.post(full_url,
|
case HTTP.post(__MODULE__, full_url,
|
||||||
headers: auth_headers(token),
|
headers: auth_headers(token),
|
||||||
json: body,
|
json: body,
|
||||||
connect_options: [timeout: 10_000],
|
connect_options: [timeout: 10_000],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule Towerops.PagerDuty.Client do
|
defmodule Towerops.PagerDuty.Client do
|
||||||
@moduledoc "PagerDuty Events API v2 client."
|
@moduledoc "PagerDuty Events API v2 client."
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@events_url "https://events.pagerduty.com/v2/enqueue"
|
@events_url "https://events.pagerduty.com/v2/enqueue"
|
||||||
|
|
@ -75,7 +77,7 @@ defmodule Towerops.PagerDuty.Client do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp send_event(body) do
|
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, %{status: status, body: resp_body}} when status in [200, 202] ->
|
||||||
{:ok, resp_body}
|
{:ok, resp_body}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule Towerops.Security.CloudflareClient do
|
||||||
pre-application blocking of brute force scanners.
|
pre-application blocking of brute force scanners.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@base_url "https://api.cloudflare.com/client/v4"
|
@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"
|
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
|
||||||
|
|
||||||
case Req.post(url,
|
case HTTP.post(__MODULE__, url,
|
||||||
json: body,
|
json: body,
|
||||||
headers: [
|
headers: [
|
||||||
{"authorization", "Bearer #{api_token}"},
|
{"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
|
{: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}"
|
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules/#{rule_id}"
|
||||||
|
|
||||||
case Req.delete(url,
|
case HTTP.delete(__MODULE__, url,
|
||||||
headers: [
|
headers: [
|
||||||
{"authorization", "Bearer #{api_token}"}
|
{"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
|
defp find_rule_by_ip(zone_id, api_token, ip_address) do
|
||||||
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
|
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
|
||||||
|
|
||||||
case Req.get(url,
|
case HTTP.get(__MODULE__, url,
|
||||||
headers: [
|
headers: [
|
||||||
{"authorization", "Bearer #{api_token}"}
|
{"authorization", "Bearer #{api_token}"}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule Towerops.Weather.Client do
|
||||||
Fetches weather by latitude/longitude — perfect for tower site locations.
|
Fetches weather by latitude/longitude — perfect for tower site locations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@base_url "https://api.openweathermap.org/data/2.5"
|
@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)
|
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, %Req.Response{status: 200, body: body}} ->
|
||||||
{:ok, body}
|
{:ok, body}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
|
||||||
import SweetXml
|
import SweetXml
|
||||||
|
|
||||||
alias Towerops.Devices.Firmware
|
alias Towerops.Devices.Firmware
|
||||||
|
alias Towerops.HTTP
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -44,7 +45,7 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
|
||||||
Returns `{:ok, body}` on success or `{:error, reason}` on failure.
|
Returns `{:ok, body}` on success or `{:error, reason}` on failure.
|
||||||
"""
|
"""
|
||||||
def fetch_rss_feed do
|
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, %Req.Response{status: 200, body: body}} ->
|
||||||
{:ok, body}
|
{:ok, body}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ defmodule ToweropsWeb.AgentLive.Show do
|
||||||
put_flash(socket, :error, t("No binary available for architecture: %{arch}", arch: arch))
|
put_flash(socket, :error, t("No binary available for architecture: %{arch}", arch: arch))
|
||||||
|
|
||||||
{:error, _reason} ->
|
{: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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule ToweropsWeb.HelpLive.Index do
|
||||||
|
|
||||||
alias Towerops.Accounts
|
alias Towerops.Accounts
|
||||||
alias Towerops.Accounts.Scope
|
alias Towerops.Accounts.Scope
|
||||||
|
alias Towerops.HTTP
|
||||||
alias Towerops.Organizations
|
alias Towerops.Organizations
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -90,7 +91,7 @@ defmodule ToweropsWeb.HelpLive.Index do
|
||||||
url =
|
url =
|
||||||
"https://www.random.org/strings/?num=1&len=24&digits=on&upperalpha=on&loweralpha=on&unique=on&format=plain&rnd=new"
|
"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}} ->
|
{:ok, %{status: 200, body: body}} ->
|
||||||
password = String.trim(body)
|
password = String.trim(body)
|
||||||
{:ok, password}
|
{:ok, password}
|
||||||
|
|
@ -99,10 +100,13 @@ defmodule ToweropsWeb.HelpLive.Index do
|
||||||
{:error, "random.org returned status #{status}"}
|
{:error, "random.org returned status #{status}"}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, Exception.message(error)}
|
{:error, format_request_error(error)}
|
||||||
end
|
end
|
||||||
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
|
defp get_current_user(socket, session) do
|
||||||
# Try to get current_user from socket assigns (if already set by on_mount)
|
# Try to get current_user from socket assigns (if already set by on_mount)
|
||||||
with nil <- Map.get(socket.assigns, :current_user),
|
with nil <- Map.get(socket.assigns, :current_user),
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,87 @@
|
||||||
defmodule Towerops.Agents.ReleaseCheckerTest do
|
defmodule Towerops.Agents.ReleaseCheckerTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
alias Towerops.Agents.ReleaseChecker
|
alias Towerops.Agents.ReleaseChecker
|
||||||
|
|
||||||
|
setup do
|
||||||
|
ReleaseChecker.invalidate_cache()
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
ReleaseChecker.invalidate_cache()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
describe "get_latest_release/0" do
|
describe "get_latest_release/0" do
|
||||||
@tag :integration
|
test "returns release info from the configured test stub" do
|
||||||
test "returns release info with version and assets" 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()
|
{:ok, release} = ReleaseChecker.get_latest_release()
|
||||||
|
assert {:ok, cached_release} = ReleaseChecker.get_latest_release()
|
||||||
|
|
||||||
assert is_binary(release.version)
|
assert release.version == "9.9.9"
|
||||||
assert is_list(release.assets)
|
assert cached_release == release
|
||||||
|
assert_receive {:release_request, "/repos/towerops-app/towerops-agent/releases/latest"}
|
||||||
assert Enum.any?(release.assets, fn asset ->
|
refute_receive {:release_request, "/repos/towerops-app/towerops-agent/releases/latest"}
|
||||||
String.contains?(asset.name, "amd64")
|
|
||||||
end)
|
|
||||||
|
|
||||||
assert Enum.any?(release.assets, fn asset ->
|
|
||||||
String.contains?(asset.name, "arm64")
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
30
test/towerops/http_test.exs
Normal file
30
test/towerops/http_test.exs
Normal 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
|
||||||
80
test/towerops/maintenance/maintenance_window_test.exs
Normal file
80
test/towerops/maintenance/maintenance_window_test.exs
Normal 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
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
defmodule Towerops.Snmp.BatchInsertTest do
|
defmodule Towerops.Snmp.BatchInsertTest do
|
||||||
use Towerops.DataCase, async: true
|
use Towerops.DataCase, async: false
|
||||||
|
|
||||||
import Towerops.AccountsFixtures
|
import Towerops.AccountsFixtures
|
||||||
|
|
||||||
|
|
|
||||||
53
test/towerops_web/components/consent_prompt_test.exs
Normal file
53
test/towerops_web/components/consent_prompt_test.exs
Normal 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
|
||||||
42
test/towerops_web/telemetry_filter_test.exs
Normal file
42
test/towerops_web/telemetry_filter_test.exs
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue