towerops/test/towerops/agent/validator_test.exs
Graham McIntire f2cd3488ee
fix heartbeat validation rejecting git-describe version strings
the version regex only allowed [a-zA-Z0-9.] in semver suffixes,
which rejected hyphens from git-describe output (e.g. 0.1.0-5-gabcdef0).
this caused every heartbeat to fail validation, preventing last_seen_at
from updating, which led to agent status showing "warning" and eventual
channel disconnect after 360s.

also accept bare git SHAs and short identifiers like "dev".
2026-02-11 11:31:19 -06:00

1519 lines
44 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 "validate_heartbeat/1 - hostname length" do
test "rejects hostname exceeding 255 characters" do
long_hostname = String.duplicate("a", 256)
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: long_hostname,
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_hostname, "Hostname too long"}} = Validator.validate_heartbeat(binary)
end
test "accepts hostname at exactly 255 characters" do
# Build a valid RFC 1123 hostname at max length: segments like "a" joined by dots
# Each segment must start/end with alnum, max 63 chars
segment = String.duplicate("a", 63)
# 63 * 4 + 3 dots = 255
hostname = Enum.join([segment, segment, segment, segment], ".")
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: hostname,
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
end
describe "validate_heartbeat/1 - version edge cases" do
test "accepts version with pre-release suffix" do
heartbeat = %AgentHeartbeat{
version: "1.2.3-beta.1",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "accepts git-describe version with commit count and SHA" do
heartbeat = %AgentHeartbeat{
version: "0.1.0-5-gabcdef0",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, validated} = Validator.validate_heartbeat(binary)
assert validated.version == "0.1.0-5-gabcdef0"
end
test "accepts bare git short SHA as version" do
heartbeat = %AgentHeartbeat{
version: "abcdef0",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "accepts 'dev' as version" do
heartbeat = %AgentHeartbeat{
version: "dev",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "accepts RFC 3339 timestamp version format" do
heartbeat = %AgentHeartbeat{
version: "2025-02-09T15:30:45Z",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, validated} = Validator.validate_heartbeat(binary)
assert validated.version == "2025-02-09T15:30:45Z"
end
test "accepts RFC 3339 timestamp with different date/time" do
heartbeat = %AgentHeartbeat{
version: "2026-12-31T23:59:59Z",
hostname: "agent02",
uptime_seconds: 200,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "rejects RFC 3339 timestamp without Z suffix" do
heartbeat = %AgentHeartbeat{
version: "2025-02-09T15:30:45",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
end
test "rejects RFC 3339 timestamp with offset instead of Z" do
heartbeat = %AgentHeartbeat{
version: "2025-02-09T15:30:45+00:00",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
end
test "rejects version with spaces" do
heartbeat = %AgentHeartbeat{
version: "1.2 .3",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
end
test "rejects version exceeding 255 characters" do
long_version = "1.2.3-" <> String.duplicate("a", 250)
heartbeat = %AgentHeartbeat{
version: long_version,
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
end
end
describe "validate_heartbeat/1 - uptime boundary" do
test "accepts uptime at max value (4_294_967_295)" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent01",
uptime_seconds: 4_294_967_295,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "rejects uptime at max value + 1" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent01",
uptime_seconds: 4_294_967_296,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_uptime, _}} = Validator.validate_heartbeat(binary)
end
end
describe "validate_metric_batch/1 - empty status" do
test "accepts empty status on sensor reading (backward compat)" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: "",
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
end
describe "validate_metric_batch/1 - all valid statuses" do
for status <- ["success", "failure", "up", "down", "ok", "error", "warning"] do
test "accepts status '#{status}'" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: unquote(status),
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
end
end
describe "validate_metric_batch/1 - neighbor discovery string lengths" do
test "rejects remote_chassis_id exceeding 255 characters" do
discovery = %NeighborDiscovery{
interface_id: "550e8400-e29b-41d4-a716-446655440002",
protocol: "lldp",
remote_chassis_id: String.duplicate("x", 256),
remote_system_name: "",
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, {:string_too_long, msg}} = Validator.validate_metric_batch(binary)
assert msg =~ "remote_chassis_id"
end
test "rejects remote_system_description exceeding 10_000 characters" 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: String.duplicate("d", 10_001),
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, {:string_too_long, msg}} = Validator.validate_metric_batch(binary)
assert msg =~ "remote_system_description"
end
test "accepts cdp protocol" do
discovery = %NeighborDiscovery{
interface_id: "550e8400-e29b-41d4-a716-446655440002",
protocol: "cdp",
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 {:ok, _} = Validator.validate_metric_batch(binary)
end
end
describe "validate_metric_batch/1 - monitoring check edge cases" do
test "rejects negative response time" do
check = %MonitoringCheck{
device_id: "550e8400-e29b-41d4-a716-446655440003",
status: "success",
response_time_ms: -1.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 "accepts response time at max boundary (60_000ms)" do
check = %MonitoringCheck{
device_id: "550e8400-e29b-41d4-a716-446655440003",
status: "success",
response_time_ms: 60_000.0,
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 response time just over max boundary" do
check = %MonitoringCheck{
device_id: "550e8400-e29b-41d4-a716-446655440003",
status: "success",
response_time_ms: 60_001.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 "accepts zero response time" do
check = %MonitoringCheck{
device_id: "550e8400-e29b-41d4-a716-446655440003",
status: "up",
response_time_ms: 0.0,
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
end
describe "validate_metric_batch/1 - timestamp boundaries" do
test "accepts timestamp at zero" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 1.0,
status: "ok",
timestamp: 0
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
test "accepts timestamp at max value (2_147_483_647)" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 1.0,
status: "ok",
timestamp: 2_147_483_647
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
test "rejects timestamp at max value + 1" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 1.0,
status: "ok",
timestamp: 2_147_483_648
}
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 negative timestamp" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 1.0,
status: "ok",
timestamp: -1
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_timestamp, _}} = Validator.validate_metric_batch(binary)
end
end
describe "validate_metric_batch/1 - empty batch" do
test "accepts empty metrics list" do
batch = %MetricBatch{metrics: []}
binary = MetricBatch.encode(batch)
assert {:ok, validated} = Validator.validate_metric_batch(binary)
assert validated.metrics == []
end
end
describe "validate_metric_batch/1 - multiple metrics" do
test "validates batch with multiple valid metrics of different types" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: "ok",
timestamp: 1_700_000_000
}
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: {:sensor_reading, reading}},
%Metric{metric_type: {:interface_stat, stat}}
]
}
binary = MetricBatch.encode(batch)
assert {:ok, validated} = Validator.validate_metric_batch(binary)
assert length(validated.metrics) == 2
end
test "rejects batch when second metric is invalid" do
valid_reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: "ok",
timestamp: 1_700_000_000
}
invalid_reading = %SensorReading{
sensor_id: "not-a-uuid",
value: 1.0,
status: "ok",
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:sensor_reading, valid_reading}},
%Metric{metric_type: {:sensor_reading, invalid_reading}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_sensor_id, _}} = Validator.validate_metric_batch(binary)
end
end
describe "validate_snmp_result/1 - timestamp" do
test "rejects SNMP result with negative timestamp" do
result = %SnmpResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_type: 1,
oid_values: %{},
timestamp: -1
}
binary = SnmpResult.encode(result)
assert {:error, {:invalid_timestamp, _}} = Validator.validate_snmp_result(binary)
end
test "rejects SNMP result with timestamp exceeding max" do
result = %SnmpResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_type: 1,
oid_values: %{},
timestamp: 2_147_483_648
}
binary = SnmpResult.encode(result)
assert {:error, {:invalid_timestamp, _}} = Validator.validate_snmp_result(binary)
end
end
describe "validate_agent_error/1 - edge cases" do
test "rejects empty job_id" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "",
message: "Some error"
}
binary = AgentError.encode(error)
assert {:error, {:invalid_job_id, _}} = Validator.validate_agent_error(binary)
end
test "accepts job_id at 255 characters" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: String.duplicate("j", 255),
message: "Some error"
}
binary = AgentError.encode(error)
assert {:ok, _} = Validator.validate_agent_error(binary)
end
test "rejects job_id exceeding 255 characters" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: String.duplicate("j", 256),
message: "Some error"
}
binary = AgentError.encode(error)
assert {:error, {:invalid_job_id, _}} = Validator.validate_agent_error(binary)
end
test "accepts empty error message" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
message: ""
}
binary = AgentError.encode(error)
assert {:ok, _} = Validator.validate_agent_error(binary)
end
test "accepts error message at exactly 1000 characters" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
message: String.duplicate("e", 1000)
}
binary = AgentError.encode(error)
assert {:ok, _} = Validator.validate_agent_error(binary)
end
test "rejects error message at 1001 characters" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
message: String.duplicate("e", 1001)
}
binary = AgentError.encode(error)
assert {:error, {:string_too_long, _}} = Validator.validate_agent_error(binary)
end
end
describe "validate_credential_test_result/1 - system description length" do
test "rejects system description exceeding 10_000 characters" do
result = %CredentialTestResult{
test_id: "550e8400-e29b-41d4-a716-446655440000",
success: true,
error_message: "",
system_description: String.duplicate("d", 10_001),
timestamp: 1_700_000_000
}
binary = CredentialTestResult.encode(result)
assert {:error, {:string_too_long, msg}} = Validator.validate_credential_test_result(binary)
assert msg =~ "System description"
end
test "accepts system description at exactly 10_000 characters" do
result = %CredentialTestResult{
test_id: "550e8400-e29b-41d4-a716-446655440000",
success: true,
error_message: "",
system_description: String.duplicate("d", 10_000),
timestamp: 1_700_000_000
}
binary = CredentialTestResult.encode(result)
assert {:ok, _} = Validator.validate_credential_test_result(binary)
end
end
describe "validate_credential_test_result/1 - error message length" do
test "rejects error message exceeding 1000 characters" do
result = %CredentialTestResult{
test_id: "550e8400-e29b-41d4-a716-446655440000",
success: false,
error_message: String.duplicate("e", 1001),
system_description: "",
timestamp: 1_700_000_000
}
binary = CredentialTestResult.encode(result)
assert {:error, {:string_too_long, _}} = Validator.validate_credential_test_result(binary)
end
end
describe "validate_credential_test_result/1 - timestamp" do
test "rejects invalid timestamp" do
result = %CredentialTestResult{
test_id: "550e8400-e29b-41d4-a716-446655440000",
success: true,
error_message: "",
system_description: "",
timestamp: 9_999_999_999
}
binary = CredentialTestResult.encode(result)
assert {:error, {:invalid_timestamp, _}} = Validator.validate_credential_test_result(binary)
end
end
describe "validate_mikrotik_result/1 - edge cases" do
test "rejects invalid device_id" do
result = %MikrotikResult{
device_id: "not-valid",
job_id: "job123",
sentences: [],
error: "",
timestamp: 1_700_000_000
}
binary = MikrotikResult.encode(result)
assert {:error, {:invalid_device_id, _}} = Validator.validate_mikrotik_result(binary)
end
test "rejects empty job_id" do
result = %MikrotikResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "",
sentences: [],
error: "",
timestamp: 1_700_000_000
}
binary = MikrotikResult.encode(result)
assert {:error, {:invalid_job_id, _}} = Validator.validate_mikrotik_result(binary)
end
test "rejects error message exceeding 1000 characters" do
result = %MikrotikResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
sentences: [],
error: String.duplicate("e", 1001),
timestamp: 1_700_000_000
}
binary = MikrotikResult.encode(result)
assert {:error, {:string_too_long, _}} = Validator.validate_mikrotik_result(binary)
end
test "rejects invalid timestamp" do
result = %MikrotikResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
sentences: [],
error: "",
timestamp: 9_999_999_999
}
binary = MikrotikResult.encode(result)
assert {:error, {:invalid_timestamp, _}} = Validator.validate_mikrotik_result(binary)
end
test "accepts empty sentences list" do
result = %MikrotikResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
sentences: [],
error: "",
timestamp: 1_700_000_000
}
binary = MikrotikResult.encode(result)
assert {:ok, _} = Validator.validate_mikrotik_result(binary)
end
test "accepts sentences at exactly 1000" do
sentences = Enum.map(1..1000, 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 {:ok, _} = Validator.validate_mikrotik_result(binary)
end
end
describe "validate_metric_batch/1 - interface stat counter boundaries" do
test "rejects negative if_out_errors" 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: -1,
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 negative if_in_discards" 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: -5,
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 negative if_out_discards" 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: -1,
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 interface UUID" do
stat = %InterfaceStat{
interface_id: "bad-uuid",
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 {:error, {:invalid_interface_id, _}} = Validator.validate_metric_batch(binary)
end
end
describe "validate_metric_batch/1 - neighbor discovery with remote IPv6" do
test "accepts remote_address as IPv6" 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: "",
remote_platform: "",
remote_port_id: "",
remote_port_description: "",
remote_address: "fe80::1",
remote_capabilities: [],
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 "rejects invalid remote_address IP" 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: "",
remote_platform: "",
remote_port_id: "",
remote_port_description: "",
remote_address: "not-an-ip",
remote_capabilities: [],
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:neighbor_discovery, discovery}}]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_ip, _}} = Validator.validate_metric_batch(binary)
end
end
describe "validate_metric_batch/1 - monitoring check with invalid device_id" do
test "rejects monitoring check with non-UUID device_id" do
check = %MonitoringCheck{
device_id: "not-valid",
status: "success",
response_time_ms: 1.0,
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:monitoring_check, check}}]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_device_id, _}} = Validator.validate_metric_batch(binary)
end
end
describe "validate_snmp_result/1 - malformed protobuf" do
test "rejects malformed protobuf binary" do
assert {:error, {:decode_error, _}} = Validator.validate_snmp_result(<<255, 255, 255>>)
end
end
describe "validate_agent_error/1 - malformed protobuf" do
test "rejects malformed protobuf binary" do
assert {:error, {:decode_error, _}} = Validator.validate_agent_error(<<255, 255, 255>>)
end
end
describe "validate_credential_test_result/1 - malformed protobuf" do
test "rejects malformed protobuf binary" do
assert {:error, {:decode_error, _}} = Validator.validate_credential_test_result(<<255, 255, 255>>)
end
end
describe "validate_mikrotik_result/1 - malformed protobuf" do
test "rejects malformed protobuf binary" do
assert {:error, {:decode_error, _}} = Validator.validate_mikrotik_result(<<255, 255, 255>>)
end
end
describe "validate_metric_batch/1 - malformed protobuf" do
test "rejects malformed protobuf binary" do
assert {:error, {:decode_error, _}} = Validator.validate_metric_batch(<<255, 255, 255>>)
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
test "accepts UUID with uppercase hex characters" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
# Test with uppercase UUID in device_id
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
test "handles hostname with hyphens" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "my-agent-01",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "rejects hostname starting with hyphen" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "-invalid",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
end
test "rejects hostname ending with hyphen" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "invalid-",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
end
test "rejects hostname with special characters" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "host_name@bad",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
end
end
end