towerops/test/towerops/monitoring/ping_test.exs
Graham McIntire c50dc08ad4 test: fix slow + flaky tests (Mox global, env pollution, pool starvation)
- Remove Mox.set_mox_global() in DPW extra test; rely on $callers
  instead so concurrent vendor SNMP tests stop intermittently failing
  with VerificationError.
- Make ToweropsWeb.Plugs.GraphQLIntrospectionTest async: false. It
  mutates Application.put_env(:towerops, :env, :prod), which polluted
  every async vendor test that reads :env via
  Towerops.Snmp.Client.phoenix_snmp_disabled/0.
- Bump test pool queue_target/queue_interval. AgentChannelTest spawns
  many `:proc_lib` channel processes that hold sandbox connections;
  the default 50ms queue caused intermittent
  'could not checkout the connection' errors.
- Tag real-System.cmd("ping") tests as :network so they're excluded
  by default. Saves ~20s on every full run.
- Make JobCleanupTask's internal 1s settle sleep configurable via
  :job_cleanup_settle_ms; the test overrides it to 0.

Plus the pending CoverageLive.Form edit-save / EIRP recompute and
TraceLive deep-link tests from before the compact.
2026-05-08 14:43:30 -05:00

244 lines
7.8 KiB
Elixir

defmodule Towerops.Monitoring.PingTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Towerops.Monitoring.Ping
# These tests focus on IP validation which happens BEFORE any system calls
describe "IP address validation" do
test "returns error for invalid IP address" do
assert {:error, :invalid_ip} = Ping.ping("not-an-ip")
end
test "returns error for empty string" do
assert {:error, :invalid_ip} = Ping.ping("")
end
test "returns error for malformed IP with out of range octets" do
assert {:error, :invalid_ip} = Ping.ping("256.256.256.256")
end
test "returns error for partial IP format" do
# Note: "192.168.1" is valid shorthand (192.168.0.1), so use trailing dot
assert {:error, :invalid_ip} = Ping.ping("192.168.1.")
end
test "returns error for IP with extra octets" do
assert {:error, :invalid_ip} = Ping.ping("192.168.1.1.1")
end
test "returns error for IP with letters" do
assert {:error, :invalid_ip} = Ping.ping("192.168.1.abc")
end
test "returns error for IP with special characters" do
assert {:error, :invalid_ip} = Ping.ping("192.168.1.1!")
end
test "rejects invalid IPv6 address" do
assert {:error, :invalid_ip} = Ping.ping("2001:0db8:85a3::8a2e:0370:gggg")
end
test "rejects malformed IPv6 address" do
assert {:error, :invalid_ip} = Ping.ping(":::1")
end
end
describe "error handling" do
test "handles nil IP address with FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Ping.ping(nil)
end
end
test "handles integer IP address with FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Ping.ping(123)
end
end
test "handles atom IP address with FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Ping.ping(:localhost)
end
end
test "handles list IP address with FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Ping.ping([192, 168, 1, 1])
end
end
test "handles non-integer timeout with FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Ping.ping("127.0.0.1", "1000")
end
end
end
describe "PingBehaviour implementation" do
test "implements ping/1 callback" do
exports = Ping.module_info(:exports)
assert {:ping, 1} in exports
end
test "implements ping/2 callback" do
exports = Ping.module_info(:exports)
assert {:ping, 2} in exports
end
end
describe "property-based tests for IP validation" do
@tag :property
property "invalid IP strings always return error from validation" do
# Use generators that produce definitely-invalid IPs (no expensive filter)
check all(
invalid_ip <-
one_of([
# Alphanumeric strings (no dots) are never valid IPs
string(:alphanumeric, min_length: 1, max_length: 15),
# IPs with invalid octets (>255)
map(integer(256..999), &"#{&1}.#{&1}.#{&1}.#{&1}"),
# IPs with letters in octet position (use ?a..?z range)
map(string(?a..?z, length: 3), &"192.168.#{&1}.1")
]),
max_runs: 10
) do
# These fail at validation, before any system call
result = Ping.ping(invalid_ip)
assert match?({:error, _}, result)
end
end
@tag :property
property "valid IPv4 addresses are accepted by inet parser" do
check all(
octet1 <- integer(0..255),
octet2 <- integer(0..255),
octet3 <- integer(0..255),
octet4 <- integer(0..255),
max_runs: 20
) do
ip = "#{octet1}.#{octet2}.#{octet3}.#{octet4}"
# Just verify IP validation passes (don't actually ping)
assert {:ok, _} = :inet.parse_address(String.to_charlist(ip))
end
end
end
# These tests verify the ping output parser behavior
# by testing the module's internal parsing logic indirectly
describe "ping output parsing (via mocked system calls)" do
# We can't easily mock System.cmd, but we can test that the module
# correctly handles various response formats by testing with valid IPs
# that return quickly (localhost)
@tag :network
test "localhost ping returns valid response format" do
result = Ping.ping("127.0.0.1", 2000)
case result do
{:ok, response_time} ->
assert is_float(response_time)
assert response_time >= 0.0
assert response_time < 2000.0
{:error, reason} ->
# Acceptable errors in test environment
assert reason in [:timeout, :command_not_found, :parse_error]
end
end
@tag :network
test "default-arity ping/1 dispatches to ping/2" do
# ping/1 calls ping(ip, 5000); we mostly want to exercise the arity
# without slowing the suite down for a real timeout.
result = Ping.ping("127.0.0.1")
case result do
{:ok, response_time} -> assert is_float(response_time)
{:error, reason} -> assert reason in [:timeout, :command_not_found, :parse_error]
end
end
@tag :network
test "unreachable IP returns :timeout error" do
# 192.0.2.0/24 is reserved for documentation per RFC 5737 — never routed.
result = Ping.ping("192.0.2.1", 1000)
# Either the OS resolves immediately (timeout) or the binary is missing.
assert match?({:error, :timeout}, result) or match?({:error, _}, result)
end
@tag :integration
test "IPv6 localhost ping returns valid response format" do
result = Ping.ping("::1", 2000)
case result do
{:ok, response_time} ->
assert is_float(response_time)
assert response_time >= 0.0
{:error, reason} ->
# IPv6 may not be available in all test environments
assert reason in [:timeout, :command_not_found, :parse_error]
end
end
end
describe "validate_ip_address/1" do
test "empty string returns invalid_ip" do
assert {:error, :invalid_ip} == Ping.validate_ip_address("")
end
test "valid IPv4 is :ok" do
assert :ok == Ping.validate_ip_address("192.168.1.1")
assert :ok == Ping.validate_ip_address("0.0.0.0")
assert :ok == Ping.validate_ip_address("255.255.255.255")
end
test "valid IPv6 is :ok" do
assert :ok == Ping.validate_ip_address("::1")
assert :ok == Ping.validate_ip_address("2001:db8::1")
end
test "invalid string returns invalid_ip" do
assert {:error, :invalid_ip} == Ping.validate_ip_address("not.an.ip")
assert {:error, :invalid_ip} == Ping.validate_ip_address("256.256.256.256")
end
end
describe "parse_ping_output/1" do
test "parses macOS format" do
output = """
PING 127.0.0.1 (127.0.0.1): 56 data bytes
round-trip min/avg/max/stddev = 0.050/0.123/0.200/0.075 ms
"""
assert {:ok, 0.123} == Ping.parse_ping_output(output)
end
test "parses Linux format" do
output = """
--- 127.0.0.1 ping statistics ---
rtt min/avg/max/mdev = 0.010/0.025/0.050/0.020 ms
"""
assert {:ok, 0.025} == Ping.parse_ping_output(output)
end
test "parses simple time= format" do
output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.123 ms"
assert {:ok, 0.123} == Ping.parse_ping_output(output)
end
test "parses time< fallback" do
output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time<0.5 ms"
assert {:ok, 0.5} == Ping.parse_ping_output(output)
end
test "returns parse_error when no known pattern present" do
assert {:error, :parse_error} == Ping.parse_ping_output("total garbage")
assert {:error, :parse_error} == Ping.parse_ping_output("")
end
end
end