towerops/test/snmpkit/snmp_lib/error_handler_test.exs
Graham McIntire 3a408a8dc1 Security hardening + performance fixes across codebase
CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
2026-06-21 17:40:50 -05:00

324 lines
9.9 KiB
Elixir

defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
use ExUnit.Case, async: true
alias SnmpKit.SnmpLib.ErrorHandler
describe "with_retry/2" do
test "succeeds on first attempt" do
fun = fn -> {:ok, :success} end
assert {:ok, :success} = ErrorHandler.with_retry(fun)
end
test "retries transient errors" do
# Simulate transient error then success
agent = Agent.start_link(fn -> 0 end)
{:ok, pid} = agent
fun = fn ->
count = Agent.get_and_update(pid, fn c -> {c, c + 1} end)
if count < 2 do
{:error, :timeout}
else
{:ok, :success_after_retry}
end
end
assert {:ok, :success_after_retry} = ErrorHandler.with_retry(fun, max_attempts: 3, base_delay: 1)
Agent.stop(pid)
end
test "fails after max retries" do
fun = fn -> {:error, :timeout} end
assert {:error, {:max_retries_exceeded, :timeout}} =
ErrorHandler.with_retry(fun, max_attempts: 3, base_delay: 1)
end
test "does not retry permanent errors" do
fun = fn -> {:error, :authentication_failed} end
# Custom retry condition that returns false for permanent errors
retry_condition = fn
:authentication_failed -> false
_ -> true
end
assert {:error, :authentication_failed} =
ErrorHandler.with_retry(fun,
max_attempts: 3,
retry_condition: retry_condition
)
end
test "handles exceptions with retry" do
agent = Agent.start_link(fn -> 0 end)
{:ok, pid} = agent
fun = fn ->
count = Agent.get_and_update(pid, fn c -> {c, c + 1} end)
if count < 2 do
raise "temporary failure"
else
{:ok, :recovered}
end
end
assert {:ok, :recovered} = ErrorHandler.with_retry(fun, max_attempts: 3, base_delay: 10)
Agent.stop(pid)
end
test "respects custom base delay" do
fun = fn -> {:error, :timeout} end
start_time = System.monotonic_time(:millisecond)
ErrorHandler.with_retry(fun,
max_attempts: 2,
base_delay: 20,
strategy: :fixed,
jitter_factor: 0
)
end_time = System.monotonic_time(:millisecond)
# Should have at least one 20ms delay (allow for some timing variance)
assert end_time - start_time >= 18
end
test "uses exponential backoff strategy" do
attempts = Agent.start_link(fn -> [] end)
{:ok, pid} = attempts
fun = fn ->
Agent.update(pid, fn list -> [System.monotonic_time(:millisecond) | list] end)
{:error, :timeout}
end
ErrorHandler.with_retry(fun,
max_attempts: 3,
base_delay: 10,
strategy: :exponential,
jitter_factor: 0
)
timestamps = Agent.get(pid, fn list -> Enum.reverse(list) end)
# Verify exponential backoff pattern (roughly)
assert length(timestamps) == 3
Agent.stop(pid)
end
test "uses linear backoff strategy" do
fun = fn -> {:error, :timeout} end
result =
ErrorHandler.with_retry(fun,
max_attempts: 3,
base_delay: 10,
strategy: :linear
)
assert {:error, {:max_retries_exceeded, :timeout}} = result
end
end
describe "classify_error/1" do
test "classifies transient network errors" do
assert :transient = ErrorHandler.classify_error(:timeout)
assert :transient = ErrorHandler.classify_error(:nxdomain)
assert :transient = ErrorHandler.classify_error(:network_unreachable)
assert :transient = ErrorHandler.classify_error(:connection_refused)
end
test "classifies transient device errors" do
assert :transient = ErrorHandler.classify_error(:device_busy)
assert :transient = ErrorHandler.classify_error(:too_big)
assert :transient = ErrorHandler.classify_error(:resource_unavailable)
end
test "classifies permanent configuration errors" do
assert :permanent = ErrorHandler.classify_error(:authentication_failed)
assert :permanent = ErrorHandler.classify_error(:community_mismatch)
assert :permanent = ErrorHandler.classify_error(:unsupported_version)
assert :permanent = ErrorHandler.classify_error(:no_such_name)
assert :permanent = ErrorHandler.classify_error(:bad_value)
assert :permanent = ErrorHandler.classify_error(:read_only)
end
test "classifies degraded performance errors" do
assert :degraded = ErrorHandler.classify_error(:slow_response)
assert :degraded = ErrorHandler.classify_error(:partial_failure)
assert :degraded = ErrorHandler.classify_error(:high_error_rate)
end
test "classifies unknown errors" do
assert :unknown = ErrorHandler.classify_error(:unknown_error)
assert :unknown = ErrorHandler.classify_error(:something_weird)
end
test "classifies network errors" do
assert :transient = ErrorHandler.classify_error({:network_error, "connection reset"})
end
end
describe "circuit breaker" do
test "starts circuit breaker successfully" do
assert {:ok, pid} = ErrorHandler.start_circuit_breaker("test-device-1")
assert Process.alive?(pid)
GenServer.stop(pid)
end
test "executes function when circuit is closed" do
{:ok, breaker} = ErrorHandler.start_circuit_breaker("test-device-2")
fun = fn -> {:ok, :result} end
assert {:ok, :result} = ErrorHandler.call_through_breaker(breaker, fun)
GenServer.stop(breaker)
end
test "opens circuit after failure threshold" do
{:ok, breaker} =
ErrorHandler.start_circuit_breaker("test-device-3", failure_threshold: 2)
failing_fun = fn -> {:error, :device_down} end
# First failure
assert {:error, :device_down} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Second failure - should open circuit
assert {:error, :device_down} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Circuit should be open now
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
GenServer.stop(breaker)
end
test "transitions from closed to half-open after recovery timeout" do
{:ok, breaker} =
ErrorHandler.start_circuit_breaker("test-device-4",
failure_threshold: 1,
recovery_timeout: 100
)
failing_fun = fn -> {:error, :timeout} end
# Trigger failure to open circuit
assert {:error, :timeout} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Circuit should be open
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Wait for recovery timeout using a message-based timer
Process.send_after(self(), :recovery_elapsed, 110)
assert_receive :recovery_elapsed, 1000
# Should allow limited calls in half-open state
success_fun = fn -> {:ok, :recovered} end
assert {:ok, :recovered} = ErrorHandler.call_through_breaker(breaker, success_fun)
GenServer.stop(breaker)
end
test "resets circuit breaker" do
{:ok, breaker} = ErrorHandler.start_circuit_breaker("test-device-5")
# Get initial state
state = GenServer.call(breaker, :get_state)
assert state == :closed
# Reset
assert :ok = GenServer.call(breaker, :reset)
# Verify still closed
state = GenServer.call(breaker, :get_state)
assert state == :closed
GenServer.stop(breaker)
end
end
describe "adaptive_timeout/2" do
test "returns base timeout for unknown device" do
timeout = ErrorHandler.adaptive_timeout("invalid.device", base_timeout: 5000)
assert timeout == 5000
end
test "returns base timeout for nil device" do
timeout = ErrorHandler.adaptive_timeout(nil, base_timeout: 5000)
assert timeout == 5000
end
test "returns base timeout for empty device" do
timeout = ErrorHandler.adaptive_timeout("", base_timeout: 5000)
assert timeout == 5000
end
test "calculates adaptive timeout for known device" do
timeout = ErrorHandler.adaptive_timeout("192.168.1.1", base_timeout: 1000, max_timeout: 60_000)
# Should be at least base timeout
assert timeout >= 1000
# Should not exceed max timeout
assert timeout <= 60_000
end
test "respects custom safety factor" do
timeout1 = ErrorHandler.adaptive_timeout("192.168.1.1", base_timeout: 1000, safety_factor: 1.0)
timeout2 = ErrorHandler.adaptive_timeout("192.168.1.1", base_timeout: 1000, safety_factor: 3.0)
# Higher safety factor should give higher timeout (or at minimum be >= base)
assert timeout2 >= timeout1
end
end
describe "get_device_stats/1" do
test "returns placeholder stats for valid device" do
assert {:ok, stats} = ErrorHandler.get_device_stats("192.168.1.1")
assert stats.device_id == "192.168.1.1"
assert stats.success_count == 100
assert stats.failure_count == 5
assert stats.circuit_state == :closed
assert stats.quarantine_until == nil
end
test "returns error for nil device" do
assert {:error, :not_found} = ErrorHandler.get_device_stats(nil)
end
test "returns error for empty device" do
assert {:error, :not_found} = ErrorHandler.get_device_stats("")
end
test "returns error for invalid device" do
assert {:error, :not_found} = ErrorHandler.get_device_stats("invalid.device")
end
end
describe "quarantine_device/2" do
test "quarantines device successfully" do
assert :ok = ErrorHandler.quarantine_device("192.168.1.1", 300_000)
end
end
describe "quarantined?/1" do
test "returns false for valid device" do
refute ErrorHandler.quarantined?("192.168.1.1")
end
test "returns false for unknown device" do
refute ErrorHandler.quarantined?("invalid.device")
end
test "returns false for nil device" do
refute ErrorHandler.quarantined?(nil)
end
end
end