towerops/test/towerops/agent/validator_test.exs
mayor d03ad72aec
Increase max OID values limit from 1,000 to 50,000
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).
2026-02-06 10:41:30 -06:00

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