Fixes discovery failures on enterprise network devices with many interfaces and routing table entries. Issue: Discovery was failing with "too_many_oids - OID values map exceeds 1000 entries" on SNMPv3 devices. Enterprise routers/switches during discovery can easily return 10k-50k OIDs from: - 100+ interfaces with 20-30 OIDs each - System info, routing tables, ARP tables, neighbor tables - Various MIB objects The original limit of 1,000 was too restrictive for real-world devices while still being intended as DoS protection. Changes: - Increase @max_oid_values from 1,000 to 50,000 - Update test to generate 50,100 OIDs (exceeds new limit) - Add comment explaining why limit is set to 50k This maintains DoS protection (prevents multi-million OID attacks) while accommodating realistic device discovery scenarios. All tests passing (5578 tests, 0 failures).
537 lines
15 KiB
Elixir
537 lines
15 KiB
Elixir
defmodule Towerops.Agent.ValidatorTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Towerops.Agent.AgentError
|
|
alias Towerops.Agent.AgentHeartbeat
|
|
alias Towerops.Agent.CredentialTestResult
|
|
alias Towerops.Agent.InterfaceStat
|
|
alias Towerops.Agent.Metric
|
|
alias Towerops.Agent.MetricBatch
|
|
alias Towerops.Agent.MikrotikResult
|
|
alias Towerops.Agent.MikrotikSentence
|
|
alias Towerops.Agent.MonitoringCheck
|
|
alias Towerops.Agent.NeighborDiscovery
|
|
alias Towerops.Agent.SensorReading
|
|
alias Towerops.Agent.SnmpResult
|
|
alias Towerops.Agent.Validator
|
|
|
|
describe "validate_heartbeat/1" do
|
|
test "validates valid heartbeat" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "1.2.3",
|
|
hostname: "agent01.example.com",
|
|
uptime_seconds: 12_345,
|
|
ip_address: "192.168.1.100"
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:ok, validated} = Validator.validate_heartbeat(binary)
|
|
assert validated.version == "1.2.3"
|
|
assert validated.hostname == "agent01.example.com"
|
|
end
|
|
|
|
test "validates heartbeat with IPv6 address" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "2.0.0",
|
|
hostname: "agent02",
|
|
uptime_seconds: 100,
|
|
ip_address: "2001:db8::1"
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "rejects invalid version format" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "not-semver",
|
|
hostname: "agent03",
|
|
uptime_seconds: 100,
|
|
ip_address: ""
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "rejects invalid hostname" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "1.0.0",
|
|
hostname: "invalid..hostname",
|
|
uptime_seconds: 100,
|
|
ip_address: ""
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "rejects excessive uptime" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "1.0.0",
|
|
hostname: "agent04",
|
|
uptime_seconds: 5_000_000_000,
|
|
ip_address: ""
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:error, {:invalid_uptime, _}} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "rejects invalid IP address" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "1.0.0",
|
|
hostname: "agent05",
|
|
uptime_seconds: 100,
|
|
ip_address: "999.999.999.999"
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:error, {:invalid_ip, _}} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "accepts empty IP address" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "1.0.0",
|
|
hostname: "agent06",
|
|
uptime_seconds: 100,
|
|
ip_address: ""
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "rejects malformed protobuf" do
|
|
invalid_binary = <<0, 1, 2, 3, 4, 5>>
|
|
assert {:error, {:decode_error, _}} = Validator.validate_heartbeat(invalid_binary)
|
|
end
|
|
end
|
|
|
|
describe "validate_metric_batch/1" do
|
|
test "validates batch with sensor readings" do
|
|
reading = %SensorReading{
|
|
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
value: 23.5,
|
|
status: "ok",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:sensor_reading, reading}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:ok, validated} = Validator.validate_metric_batch(binary)
|
|
assert length(validated.metrics) == 1
|
|
end
|
|
|
|
test "validates batch with interface stats" do
|
|
stat = %InterfaceStat{
|
|
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
if_in_octets: 1000,
|
|
if_out_octets: 2000,
|
|
if_in_errors: 0,
|
|
if_out_errors: 0,
|
|
if_in_discards: 0,
|
|
if_out_discards: 0,
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:interface_stat, stat}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "validates batch with neighbor discovery" do
|
|
discovery = %NeighborDiscovery{
|
|
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
|
protocol: "lldp",
|
|
remote_chassis_id: "00:11:22:33:44:55",
|
|
remote_system_name: "switch01",
|
|
remote_system_description: "Cisco IOS",
|
|
remote_platform: "C9300",
|
|
remote_port_id: "Gi1/0/1",
|
|
remote_port_description: "uplink",
|
|
remote_address: "192.168.1.1",
|
|
remote_capabilities: ["bridge", "router"],
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:neighbor_discovery, discovery}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "validates batch with monitoring check" do
|
|
check = %MonitoringCheck{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440003",
|
|
status: "success",
|
|
response_time_ms: 12.5,
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:monitoring_check, check}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects invalid sensor UUID" do
|
|
reading = %SensorReading{
|
|
sensor_id: "not-a-uuid",
|
|
value: 23.5,
|
|
status: "ok",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:sensor_reading, reading}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:invalid_sensor_id, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects invalid status" do
|
|
reading = %SensorReading{
|
|
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
value: 23.5,
|
|
status: "invalid_status",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:sensor_reading, reading}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:invalid_status, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects negative counters" do
|
|
stat = %InterfaceStat{
|
|
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
if_in_octets: -1000,
|
|
if_out_octets: 2000,
|
|
if_in_errors: 0,
|
|
if_out_errors: 0,
|
|
if_in_discards: 0,
|
|
if_out_discards: 0,
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:interface_stat, stat}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:invalid_counter, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects invalid protocol" do
|
|
discovery = %NeighborDiscovery{
|
|
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
|
protocol: "invalid",
|
|
remote_chassis_id: "00:11:22:33:44:55",
|
|
remote_system_name: "switch01",
|
|
remote_system_description: "",
|
|
remote_platform: "",
|
|
remote_port_id: "",
|
|
remote_port_description: "",
|
|
remote_address: "",
|
|
remote_capabilities: [],
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:neighbor_discovery, discovery}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:invalid_protocol, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects excessive response time" do
|
|
check = %MonitoringCheck{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440003",
|
|
status: "success",
|
|
response_time_ms: 99_999.0,
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:monitoring_check, check}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:invalid_response_time, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects future timestamp" do
|
|
reading = %SensorReading{
|
|
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
value: 23.5,
|
|
status: "ok",
|
|
timestamp: 9_999_999_999
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:sensor_reading, reading}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:invalid_timestamp, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "rejects too many capabilities" do
|
|
discovery = %NeighborDiscovery{
|
|
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
|
protocol: "lldp",
|
|
remote_chassis_id: "",
|
|
remote_system_name: "",
|
|
remote_system_description: "",
|
|
remote_platform: "",
|
|
remote_port_id: "",
|
|
remote_port_description: "",
|
|
remote_address: "",
|
|
remote_capabilities: Enum.map(1..25, &"cap#{&1}"),
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [
|
|
%Metric{metric_type: {:neighbor_discovery, discovery}}
|
|
]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:error, {:too_many_capabilities, _}} = Validator.validate_metric_batch(binary)
|
|
end
|
|
end
|
|
|
|
describe "validate_snmp_result/1" do
|
|
test "validates valid SNMP result" do
|
|
result = %SnmpResult{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_type: 1,
|
|
oid_values: %{"1.3.6.1.2.1.1.1.0" => "Test Device"},
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = SnmpResult.encode(result)
|
|
assert {:ok, validated} = Validator.validate_snmp_result(binary)
|
|
assert validated.device_id == result.device_id
|
|
end
|
|
|
|
test "rejects invalid device UUID" do
|
|
result = %SnmpResult{
|
|
device_id: "not-a-uuid",
|
|
job_type: 1,
|
|
oid_values: %{},
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = SnmpResult.encode(result)
|
|
assert {:error, {:invalid_device_id, _}} = Validator.validate_snmp_result(binary)
|
|
end
|
|
|
|
test "rejects too many OID values" do
|
|
# Create map with more than max allowed OIDs (max is 50_000)
|
|
oid_values = Map.new(1..50_100, fn i -> {"1.3.6.1.#{i}", "value"} end)
|
|
|
|
result = %SnmpResult{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_type: 1,
|
|
oid_values: oid_values,
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = SnmpResult.encode(result)
|
|
assert {:error, {:too_many_oids, _}} = Validator.validate_snmp_result(binary)
|
|
end
|
|
end
|
|
|
|
describe "validate_agent_error/1" do
|
|
test "validates valid agent error" do
|
|
error = %AgentError{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_id: "job123",
|
|
message: "Connection timeout"
|
|
}
|
|
|
|
binary = AgentError.encode(error)
|
|
assert {:ok, validated} = Validator.validate_agent_error(binary)
|
|
assert validated.message == "Connection timeout"
|
|
end
|
|
|
|
test "rejects excessively long error message" do
|
|
long_message = String.duplicate("error ", 250)
|
|
|
|
error = %AgentError{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_id: "job123",
|
|
message: long_message
|
|
}
|
|
|
|
binary = AgentError.encode(error)
|
|
assert {:error, {:string_too_long, _}} = Validator.validate_agent_error(binary)
|
|
end
|
|
end
|
|
|
|
describe "validate_credential_test_result/1" do
|
|
test "validates successful credential test" do
|
|
result = %CredentialTestResult{
|
|
test_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
success: true,
|
|
error_message: "",
|
|
system_description: "Cisco IOS Software",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = CredentialTestResult.encode(result)
|
|
assert {:ok, validated} = Validator.validate_credential_test_result(binary)
|
|
assert validated.success == true
|
|
end
|
|
|
|
test "validates failed credential test" do
|
|
result = %CredentialTestResult{
|
|
test_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
success: false,
|
|
error_message: "Authentication failed",
|
|
system_description: "",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = CredentialTestResult.encode(result)
|
|
assert {:ok, validated} = Validator.validate_credential_test_result(binary)
|
|
assert validated.success == false
|
|
end
|
|
|
|
test "rejects invalid test ID" do
|
|
result = %CredentialTestResult{
|
|
test_id: "invalid-id",
|
|
success: true,
|
|
error_message: "",
|
|
system_description: "",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = CredentialTestResult.encode(result)
|
|
assert {:error, {:invalid_test_id, _}} = Validator.validate_credential_test_result(binary)
|
|
end
|
|
end
|
|
|
|
describe "validate_mikrotik_result/1" do
|
|
test "validates valid MikroTik result" do
|
|
sentence = %MikrotikSentence{
|
|
attributes: %{"name" => "ether1", "running" => "true"}
|
|
}
|
|
|
|
result = %MikrotikResult{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_id: "job123",
|
|
sentences: [sentence],
|
|
error: "",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = MikrotikResult.encode(result)
|
|
assert {:ok, validated} = Validator.validate_mikrotik_result(binary)
|
|
assert length(validated.sentences) == 1
|
|
end
|
|
|
|
test "rejects too many sentences" do
|
|
sentences =
|
|
Enum.map(1..1100, fn _ ->
|
|
%MikrotikSentence{attributes: %{}}
|
|
end)
|
|
|
|
result = %MikrotikResult{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_id: "job123",
|
|
sentences: sentences,
|
|
error: "",
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = MikrotikResult.encode(result)
|
|
assert {:error, {:too_many_sentences, _}} = Validator.validate_mikrotik_result(binary)
|
|
end
|
|
end
|
|
|
|
describe "edge cases" do
|
|
test "handles empty strings gracefully" do
|
|
heartbeat = %AgentHeartbeat{
|
|
version: "",
|
|
hostname: "",
|
|
uptime_seconds: 0,
|
|
ip_address: ""
|
|
}
|
|
|
|
binary = AgentHeartbeat.encode(heartbeat)
|
|
# Empty version should be accepted (backward compat)
|
|
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
|
end
|
|
|
|
test "handles zero values" do
|
|
stat = %InterfaceStat{
|
|
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
if_in_octets: 0,
|
|
if_out_octets: 0,
|
|
if_in_errors: 0,
|
|
if_out_errors: 0,
|
|
if_in_discards: 0,
|
|
if_out_discards: 0,
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
batch = %MetricBatch{
|
|
metrics: [%Metric{metric_type: {:interface_stat, stat}}]
|
|
}
|
|
|
|
binary = MetricBatch.encode(batch)
|
|
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
|
end
|
|
|
|
test "handles empty collections" do
|
|
result = %SnmpResult{
|
|
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
job_type: 1,
|
|
oid_values: %{},
|
|
timestamp: 1_700_000_000
|
|
}
|
|
|
|
binary = SnmpResult.encode(result)
|
|
assert {:ok, _} = Validator.validate_snmp_result(binary)
|
|
end
|
|
end
|
|
end
|