towerops/lib/towerops/netbox/client.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

160 lines
4.3 KiB
Elixir

defmodule Towerops.NetBox.Client do
@moduledoc """
HTTP client for the NetBox REST API.
Handles authentication, pagination, and common CRUD operations
against a user-provided NetBox instance.
"""
alias Towerops.HTTP
require Logger
@doc """
Test connectivity and authentication against a NetBox instance.
Returns `{:ok, info}` with the NetBox version or `{:error, reason}`.
"""
def test_connection(url, token) do
case get(url, "/api/status/", token) do
{:ok, %{"netbox-version" => version}} ->
{:ok, "Connected to NetBox #{version}"}
{:ok, %{"django-version" => _}} ->
{:ok, "Connected to NetBox"}
{:ok, _body} ->
{:ok, "Connected to NetBox"}
{:error, :unauthorized} ->
{:error, "Authentication failed — check your API token"}
{:error, :not_found} ->
{:error, "NetBox API not found at this URL — check the URL"}
{:error, reason} ->
{:error, "Connection failed: #{reason}"}
end
end
@doc """
List devices from NetBox with optional filters.
"""
def list_devices(url, token, params \\ %{}) do
get(url, "/api/dcim/devices/", token, params)
end
@doc """
List sites from NetBox with optional filters.
"""
def list_sites(url, token, params \\ %{}) do
get(url, "/api/dcim/sites/", token, params)
end
@doc """
List IP addresses from NetBox with optional filters.
"""
def list_ip_addresses(url, token, params \\ %{}) do
get(url, "/api/ipam/ip-addresses/", token, params)
end
@doc """
List interfaces from NetBox with optional filters.
"""
def list_interfaces(url, token, params \\ %{}) do
get(url, "/api/dcim/interfaces/", token, params)
end
@doc """
Create or update a device in NetBox.
"""
def upsert_device(url, token, attrs) do
post(url, "/api/dcim/devices/", token, attrs)
end
@doc """
Create or update a site in NetBox.
"""
def upsert_site(url, token, attrs) do
post(url, "/api/dcim/sites/", token, attrs)
end
# -- HTTP helpers --
defp get(base_url, path, token, params \\ %{}) do
with {:ok, url} <- normalize_url(base_url) do
full_url = url <> path
case HTTP.get(__MODULE__, full_url,
headers: auth_headers(token),
params: params,
connect_options: [timeout: 10_000],
receive_timeout: 15_000
) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
{:ok, %Req.Response{status: 401}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: 403}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: 404}} ->
{:error, :not_found}
{:ok, %Req.Response{status: status}} ->
{:error, "HTTP #{status}"}
{:error, %Req.TransportError{reason: reason}} ->
{:error, "#{reason}"}
{:error, reason} ->
{:error, inspect(reason)}
end
end
end
defp post(base_url, path, token, body) do
with {:ok, url} <- normalize_url(base_url) do
full_url = url <> path
case HTTP.post(__MODULE__, full_url,
headers: auth_headers(token),
json: body,
connect_options: [timeout: 10_000],
receive_timeout: 15_000
) do
{:ok, %Req.Response{status: status, body: body}} when status in [200, 201] ->
{:ok, body}
{:ok, %Req.Response{status: 401}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, "HTTP #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, inspect(reason)}
end
end
end
defp auth_headers(token) do
[{"authorization", "Token #{token}"}, {"accept", "application/json"}]
end
@invalid_url_error "NetBox URL is missing or invalid — a full URL with https:// is required"
@doc false
def normalize_url(nil), do: {:error, @invalid_url_error}
def normalize_url(url) when is_binary(url) do
trimmed = url |> String.trim() |> String.trim_trailing("/")
trimmed |> URI.parse() |> check_scheme(trimmed)
end
def normalize_url(_), do: {:error, @invalid_url_error}
defp check_scheme(%URI{scheme: scheme}, url) when scheme in ["http", "https"], do: {:ok, url}
defp check_scheme(_uri, _url), do: {:error, @invalid_url_error}
end