towerops/test/towerops/proto/agent_test.exs
2026-06-13 14:25:19 -05:00

1707 lines
50 KiB
Elixir

defmodule Towerops.Agent.ProtoTest do
@moduledoc """
Tests for Protocol Buffer generated modules.
These tests verify that encoding and decoding works correctly for all
protobuf message types used in agent communication. The
implementation uses {:ok, struct} tuples for decode where available,
and some modules are encode-only.
"""
use ExUnit.Case, async: true
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.CheckResult
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.HeartbeatMetadata
alias Towerops.Agent.HeartbeatResponse
alias Towerops.Agent.LldpTopologyResult
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MikrotikSentence
alias Towerops.Agent.MonitoringCheck
alias Towerops.Agent.NeighborDiscovery
alias Towerops.Agent.Sensor
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
alias Towerops.Proto.Decode
alias Towerops.Proto.Encode
alias Towerops.Proto.Types.AgentConfig
alias Towerops.Proto.Types.Check
alias Towerops.Proto.Types.CheckList
alias Towerops.Proto.Types.Device
alias Towerops.Proto.Types.HttpCheckConfig
alias Towerops.Proto.Types.LldpNeighbor
@device_uuid "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
@job_uuid "b2c3d4e5-f6a7-8901-bcde-f12345678901"
describe "AgentError" do
test "encodes and decodes correctly" do
error = %AgentError{
device_id: @device_uuid,
job_id: @job_uuid,
message: "SNMP timeout",
timestamp: 1_234_567_890
}
encoded = AgentError.encode(error)
assert {:ok, decoded} = AgentError.decode(encoded)
assert decoded.device_id == error.device_id
assert decoded.job_id == error.job_id
assert decoded.message == error.message
assert decoded.timestamp == error.timestamp
end
test "decodes empty device_id as error" do
error = %AgentError{}
encoded = AgentError.encode(error)
assert {:error, _reason} = AgentError.decode(encoded)
end
end
describe "AgentHeartbeat" do
test "encodes and decodes correctly" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent-host",
uptime_seconds: 3600,
ip_address: "192.168.1.100"
}
encoded = AgentHeartbeat.encode(heartbeat)
assert {:ok, decoded} = AgentHeartbeat.decode(encoded)
assert decoded.version == heartbeat.version
assert decoded.hostname == heartbeat.hostname
assert decoded.uptime_seconds == heartbeat.uptime_seconds
assert decoded.ip_address == heartbeat.ip_address
end
test "encodes empty message" do
heartbeat = %AgentHeartbeat{}
encoded = AgentHeartbeat.encode(heartbeat)
assert {:ok, decoded} = AgentHeartbeat.decode(encoded)
assert decoded.version == ""
assert decoded.hostname == ""
assert decoded.uptime_seconds == 0
assert decoded.ip_address == ""
end
end
describe "JobType enum" do
test "encodes DISCOVER constant" do
job = %AgentJob{job_id: "test", job_type: :DISCOVER, device_id: "dev-1"}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_type == :DISCOVER
end
test "encodes POLL constant" do
job = %AgentJob{job_id: "test", job_type: :POLL, device_id: "dev-1"}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_type == :POLL
end
end
describe "QueryType enum" do
test "encodes GET constant via AgentJob round-trip" do
query = %SnmpQuery{query_type: :GET, oids: ["1.3.6.1.2.1.1.1.0"]}
job = %AgentJob{
job_id: "test-get",
job_type: :POLL,
device_id: "dev-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: [query]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert hd(decoded.queries).query_type == :GET
end
test "encodes WALK constant via AgentJob round-trip" do
query = %SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.2.2.1"]}
job = %AgentJob{
job_id: "test-walk",
job_type: :POLL,
device_id: "dev-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: [query]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert hd(decoded.queries).query_type == :WALK
end
end
describe "SnmpDevice" do
test "encodes correctly" do
device = %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161
}
encoded = SnmpDevice.encode(device)
assert is_binary(encoded)
end
test "encodes empty message" do
device = %SnmpDevice{}
encoded = SnmpDevice.encode(device)
assert is_binary(encoded)
end
test "round-trips through AgentJob" do
device = %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161
}
job = %AgentJob{
job_id: "test-device",
job_type: :POLL,
device_id: "dev-1",
snmp_device: device,
queries: []
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.snmp_device.ip == device.ip
assert decoded.snmp_device.community == device.community
assert decoded.snmp_device.version == device.version
assert decoded.snmp_device.port == device.port
end
end
describe "SnmpQuery" do
test "encodes GET query" do
query = %SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]
}
encoded = SnmpQuery.encode(query)
assert is_binary(encoded)
end
test "encodes WALK query" do
query = %SnmpQuery{
query_type: :WALK,
oids: ["1.3.6.1.2.1.2.2.1"]
}
encoded = SnmpQuery.encode(query)
assert is_binary(encoded)
end
test "encodes empty message" do
query = %SnmpQuery{}
encoded = SnmpQuery.encode(query)
assert is_binary(encoded)
end
test "round-trips through AgentJob" do
queries = [
%SnmpQuery{query_type: :GET, oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]},
%SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.2.2.1"]}
]
job = %AgentJob{
job_id: "test-queries",
job_type: :POLL,
device_id: "dev-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: queries
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert length(decoded.queries) == 2
assert Enum.at(decoded.queries, 0).query_type == :GET
assert Enum.at(decoded.queries, 0).oids == ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]
assert Enum.at(decoded.queries, 1).query_type == :WALK
assert Enum.at(decoded.queries, 1).oids == ["1.3.6.1.2.1.2.2.1"]
end
end
describe "AgentJob" do
test "encodes and decodes DISCOVER job" do
job = %AgentJob{
job_id: "discover:device-123",
job_type: :DISCOVER,
device_id: "device-123",
snmp_device: %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161
},
queries: [
%SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.2.1.1.1.0"]
}
]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.job_type == :DISCOVER
assert decoded.device_id == job.device_id
assert decoded.snmp_device.ip == job.snmp_device.ip
assert decoded.snmp_device.community == job.snmp_device.community
assert decoded.snmp_device.version == job.snmp_device.version
assert decoded.snmp_device.port == job.snmp_device.port
assert length(decoded.queries) == 1
assert hd(decoded.queries).query_type == :GET
assert hd(decoded.queries).oids == ["1.3.6.1.2.1.1.1.0"]
end
test "encodes and decodes POLL job" do
job = %AgentJob{
job_id: "poll:device-123",
job_type: :POLL,
device_id: "device-123",
snmp_device: %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161
},
queries: [
%SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.4.1.14988.1.1.3.8.0"]
}
]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.job_type == :POLL
assert decoded.device_id == job.device_id
assert decoded.snmp_device.ip == job.snmp_device.ip
assert length(decoded.queries) == 1
end
test "encodes empty message" do
job = %AgentJob{}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == ""
assert decoded.job_type == :DISCOVER
assert decoded.device_id == ""
assert decoded.snmp_device == nil
assert decoded.queries == []
end
end
describe "AgentJobList" do
test "encodes list of jobs" do
job_list = %AgentJobList{
jobs: [
%AgentJob{
job_id: "poll:device-1",
job_type: :POLL,
device_id: "device-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: []
},
%AgentJob{
job_id: "discover:device-2",
job_type: :DISCOVER,
device_id: "device-2",
snmp_device: %SnmpDevice{ip: "192.168.1.2"},
queries: []
}
]
}
encoded = AgentJobList.encode(job_list)
assert is_binary(encoded)
end
test "encodes empty job list" do
job_list = %AgentJobList{jobs: []}
encoded = AgentJobList.encode(job_list)
assert is_binary(encoded)
end
end
describe "SnmpResult" do
test "encodes and decodes with oid_values map" do
result = %SnmpResult{
device_id: @device_uuid,
job_type: :POLL,
oid_values: %{
"1.3.6.1.2.1.1.1.0" => "Cisco IOS",
"1.3.6.1.2.1.1.3.0" => "12345"
},
timestamp: 1_234_567_890
}
encoded = SnmpResult.encode(result)
assert {:ok, decoded} = SnmpResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_type == :POLL
assert decoded.oid_values == result.oid_values
assert decoded.timestamp == result.timestamp
end
test "encodes and decodes DISCOVER result" do
result = %SnmpResult{
device_id: @device_uuid,
job_type: :DISCOVER,
oid_values: %{},
timestamp: 1_234_567_890
}
encoded = SnmpResult.encode(result)
assert {:ok, decoded} = SnmpResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_type == :DISCOVER
assert decoded.timestamp == result.timestamp
end
test "decodes empty device_id as error" do
result = %SnmpResult{}
encoded = SnmpResult.encode(result)
assert {:error, _reason} = SnmpResult.decode(encoded)
end
end
describe "MonitoringCheck" do
test "encodes and decodes success check" do
check = %MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 45.2,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
assert {:ok, decoded} = MonitoringCheck.decode(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
assert decoded.response_time_ms == check.response_time_ms
assert decoded.timestamp == check.timestamp
end
test "encodes and decodes failure check" do
check = %MonitoringCheck{
device_id: @device_uuid,
status: "failure",
response_time_ms: 0.0,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
assert {:ok, decoded} = MonitoringCheck.decode(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
assert decoded.response_time_ms == check.response_time_ms
assert decoded.timestamp == check.timestamp
end
test "decodes empty device_id as error" do
check = %MonitoringCheck{}
encoded = MonitoringCheck.encode(check)
assert {:error, _reason} = MonitoringCheck.decode(encoded)
end
end
describe "NeighborDiscovery" do
test "encodes LLDP neighbor" do
neighbor = %NeighborDiscovery{
interface_id: "interface-123",
protocol: "LLDP",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "neighbor-switch",
remote_system_description: "Cisco IOS",
remote_platform: "cisco",
remote_port_id: "GigabitEthernet1/0/1",
remote_port_description: "Uplink to Core",
remote_address: "192.168.1.2",
remote_capabilities: ["bridge", "router"],
timestamp: 1_234_567_890
}
encoded = NeighborDiscovery.encode(neighbor)
assert is_binary(encoded)
end
test "encodes CDP neighbor" do
neighbor = %NeighborDiscovery{
interface_id: "interface-456",
protocol: "CDP",
remote_chassis_id: "Router-2",
remote_system_name: "core-router",
remote_platform: "Cisco 2960",
remote_port_id: "FastEthernet0/1",
timestamp: 1_234_567_890
}
encoded = NeighborDiscovery.encode(neighbor)
assert is_binary(encoded)
end
test "encodes empty message" do
neighbor = %NeighborDiscovery{}
encoded = NeighborDiscovery.encode(neighbor)
assert is_binary(encoded)
end
end
describe "Sensor.MetadataEntry" do
test "creates struct with key and value" do
entry = %Sensor.MetadataEntry{
key: "location",
value: "CPU Core 1"
}
assert entry.key == "location"
assert entry.value == "CPU Core 1"
end
test "creates empty entry" do
entry = %Sensor.MetadataEntry{}
assert entry.key == ""
assert entry.value == ""
end
end
describe "Sensor (protobuf)" do
test "encodes and decodes sensor with metadata" do
sensor = %Sensor{
id: "sensor-123",
type: "temperature",
oid: "1.3.6.1.4.1.14988.1.1.3.10.0",
divisor: 10.0,
unit: "celsius",
metadata: %{
"location" => "CPU Core 1",
"threshold" => "80"
}
}
encoded = Sensor.encode(sensor)
decoded = Sensor.decode(encoded)
assert decoded.id == sensor.id
assert decoded.type == sensor.type
assert decoded.oid == sensor.oid
assert decoded.divisor == sensor.divisor
assert decoded.unit == sensor.unit
assert decoded.metadata == sensor.metadata
end
test "encodes and decodes sensor without metadata" do
sensor = %Sensor{
id: "sensor-456",
type: "voltage",
oid: "1.3.6.1.4.1.14988.1.1.3.8.0",
divisor: 10.0,
unit: "volts"
}
encoded = Sensor.encode(sensor)
decoded = Sensor.decode(encoded)
assert decoded.id == sensor.id
assert decoded.type == sensor.type
assert decoded.oid == sensor.oid
assert decoded.divisor == sensor.divisor
assert decoded.unit == sensor.unit
end
test "encodes empty message" do
sensor = %Sensor{}
encoded = Sensor.encode(sensor)
decoded = Sensor.decode(encoded)
assert decoded.id == ""
assert decoded.type == ""
assert decoded.oid == ""
assert decoded.divisor == 0.0
assert decoded.unit == ""
assert decoded.metadata == %{}
end
end
describe "CheckResult" do
test "encodes and decodes success check result" do
result = %CheckResult{
check_id: @device_uuid,
status: 0,
output: "OK",
response_time_ms: 12.3,
timestamp: 1_234_567_890
}
encoded = CheckResult.encode(result)
assert {:ok, decoded} = CheckResult.decode(encoded)
assert decoded.check_id == result.check_id
assert decoded.status == result.status
assert decoded.output == result.output
assert decoded.response_time_ms == result.response_time_ms
assert decoded.timestamp == result.timestamp
end
test "decodes empty check_id as error" do
result = %CheckResult{timestamp: 1_234_567_890}
encoded = CheckResult.encode(result)
assert {:error, _reason} = CheckResult.decode(encoded)
end
end
describe "CredentialTestResult" do
test "encodes and decodes success result" do
result = %CredentialTestResult{
test_id: @device_uuid,
success: true,
system_description: "Router OS 7.1",
timestamp: 1_234_567_890
}
encoded = CredentialTestResult.encode(result)
assert {:ok, decoded} = CredentialTestResult.decode(encoded)
assert decoded.test_id == result.test_id
assert decoded.success == result.success
assert decoded.system_description == result.system_description
assert decoded.timestamp == result.timestamp
end
test "encodes and decodes failure result" do
result = %CredentialTestResult{
test_id: @device_uuid,
success: false,
error_message: "Authentication failed",
timestamp: 1_234_567_890
}
encoded = CredentialTestResult.encode(result)
assert {:ok, decoded} = CredentialTestResult.decode(encoded)
assert decoded.test_id == result.test_id
assert decoded.success == false
assert decoded.error_message == result.error_message
end
test "decodes empty test_id as error" do
result = %CredentialTestResult{timestamp: 1_234_567_890}
encoded = CredentialTestResult.encode(result)
assert {:error, _reason} = CredentialTestResult.decode(encoded)
end
end
describe "MikrotikResult" do
test "encodes and decodes with sentences" do
result = %MikrotikResult{
device_id: @device_uuid,
job_id: @job_uuid,
sentences: [
%MikrotikSentence{attributes: %{"name" => "ether1", "mac-address" => "00:11:22:33:44:55"}},
%MikrotikSentence{attributes: %{"name" => "ether2", "mac-address" => "66:77:88:99:AA:BB"}}
],
timestamp: 1_234_567_890
}
encoded = MikrotikResult.encode(result)
assert {:ok, decoded} = MikrotikResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_id == result.job_id
assert length(decoded.sentences) == 2
assert hd(decoded.sentences).attributes["name"] == "ether1"
end
test "encodes and decodes with error" do
result = %MikrotikResult{
device_id: @device_uuid,
job_id: @job_uuid,
sentences: [],
error: "Connection refused",
timestamp: 1_234_567_890
}
encoded = MikrotikResult.encode(result)
assert {:ok, decoded} = MikrotikResult.decode(encoded)
assert decoded.error == result.error
end
test "decodes empty device_id as error" do
result = %MikrotikResult{job_id: @job_uuid, timestamp: 1_234_567_890}
encoded = MikrotikResult.encode(result)
assert {:error, _reason} = MikrotikResult.decode(encoded)
end
end
describe "LldpTopologyResult" do
test "decodes correctly with neighbors" do
result = %Towerops.Proto.Types.LldpTopologyResult{
device_id: @device_uuid,
job_id: @job_uuid,
local_system_name: "switch01",
neighbors: [
%LldpNeighbor{
neighbor_name: "router01",
local_port: "Eth1/0/1",
remote_port: "Gi0/1",
remote_port_id: "Gi0/1",
management_addresses: ["192.168.1.1"]
}
],
timestamp: 1_234_567_890
}
encoded = Encode.encode_lldp_topology_result(result)
decoded = LldpTopologyResult.decode(encoded)
assert {:ok, decoded_result} = decoded
assert decoded_result.device_id == result.device_id
assert decoded_result.job_id == result.job_id
assert decoded_result.local_system_name == result.local_system_name
assert length(decoded_result.neighbors) == 1
end
test "decodes empty device_id as error" do
result = %Towerops.Proto.Types.LldpTopologyResult{
device_id: "",
job_id: @job_uuid,
local_system_name: "",
neighbors: [],
timestamp: 1_234_567_890
}
encoded = Encode.encode_lldp_topology_result(result)
assert {:error, _reason} = LldpTopologyResult.decode(encoded)
end
end
describe "AgentJob with mikrotik" do
test "encodes job with mikrotik_device" do
job = %AgentJob{
job_id: "mikrotik:device-123",
job_type: :POLL,
device_id: "device-123",
mikrotik_device: %MikrotikDevice{
ip: "192.168.88.1",
port: 8728,
username: "admin",
password: "password",
use_ssl: false,
ssh_port: 22
},
mikrotik_commands: [
%MikrotikCommand{command: "/interface/print", args: %{}}
]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.mikrotik_device.ip == "192.168.88.1"
assert decoded.mikrotik_device.port == 8728
assert hd(decoded.mikrotik_commands).command == "/interface/print"
end
end
describe "Validation error paths" do
test "heartbeat rejects version string that is too long" do
hb = %AgentHeartbeat{
version: String.duplicate("a", 256),
hostname: "test",
uptime_seconds: 60,
ip_address: "127.0.0.1"
}
encoded = AgentHeartbeat.encode(hb)
assert {:error, _reason} = AgentHeartbeat.decode(encoded)
end
test "heartbeat rejects uptime that is too large" do
hb = %AgentHeartbeat{
version: "1.0",
hostname: "test",
uptime_seconds: 5_000_000_000,
ip_address: "127.0.0.1"
}
encoded = AgentHeartbeat.encode(hb)
assert {:error, _reason} = AgentHeartbeat.decode(encoded)
end
test "agent error rejects message that is too long" do
error = %AgentError{
device_id: @device_uuid,
job_id: @job_uuid,
message: String.duplicate("a", 1001),
timestamp: 1_234_567_890
}
encoded = AgentError.encode(error)
assert {:error, _reason} = AgentError.decode(encoded)
end
test "monitoring check rejects response time that is negative" do
check = %MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: -1.0,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
assert {:error, _reason} = MonitoringCheck.decode(encoded)
end
test "monitoring check rejects negative timestamp" do
check = %MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 10.0,
timestamp: -1
}
encoded = MonitoringCheck.encode(check)
assert {:error, _reason} = MonitoringCheck.decode(encoded)
end
test "snmp result rejects device_id with invalid UUID format" do
result = %SnmpResult{
device_id: "not-a-uuid",
job_type: :POLL,
oid_values: %{},
timestamp: 1_234_567_890
}
encoded = SnmpResult.encode(result)
assert {:error, _reason} = SnmpResult.decode(encoded)
end
test "monitoring check rejects status string that is too long" do
check = %MonitoringCheck{
device_id: @device_uuid,
status: String.duplicate("a", 256),
response_time_ms: 10.0,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
assert {:error, _reason} = MonitoringCheck.decode(encoded)
end
test "monitoring check rejects response time that is too large" do
check = %MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 1_000_000,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
assert {:error, _reason} = MonitoringCheck.decode(encoded)
end
test "credential test result rejects negative timestamp" do
result = %CredentialTestResult{
test_id: @device_uuid,
success: true,
timestamp: -1
}
encoded = CredentialTestResult.encode(result)
assert {:error, _reason} = CredentialTestResult.decode(encoded)
end
test "credential test result rejects too long system_description" do
result = %CredentialTestResult{
test_id: @device_uuid,
success: true,
system_description: String.duplicate("a", 10_001),
timestamp: 1_234_567_890
}
encoded = CredentialTestResult.encode(result)
assert {:error, _reason} = CredentialTestResult.decode(encoded)
end
test "check result rejects response time that is too large" do
result = %CheckResult{
check_id: @device_uuid,
status: 0,
output: "OK",
response_time_ms: 1_000_000,
timestamp: 1_234_567_890
}
encoded = CheckResult.encode(result)
assert {:error, _reason} = CheckResult.decode(encoded)
end
test "check result rejects too long output" do
result = %CheckResult{
check_id: @device_uuid,
status: 0,
output: String.duplicate("a", 10_001),
response_time_ms: 10.0,
timestamp: 1_234_567_890
}
encoded = CheckResult.encode(result)
assert {:error, _reason} = CheckResult.decode(encoded)
end
test "lldp topology result rejects too long local_system_name" do
result = %Towerops.Proto.Types.LldpTopologyResult{
device_id: @device_uuid,
job_id: @job_uuid,
local_system_name: String.duplicate("a", 10_001),
neighbors: [],
timestamp: 1_234_567_890
}
encoded = Encode.encode_lldp_topology_result(result)
assert {:error, _reason} = LldpTopologyResult.decode(encoded)
end
test "check result rejects negative timestamp" do
result = %CheckResult{
check_id: @device_uuid,
status: 0,
output: "OK",
response_time_ms: 10.0,
timestamp: -1
}
encoded = CheckResult.encode(result)
assert {:error, _reason} = CheckResult.decode(encoded)
end
test "mikrotik result rejects too many sentences" do
sentences =
Enum.map(1..1001, fn i ->
%MikrotikSentence{attributes: %{"name" => "item#{i}"}}
end)
result = %MikrotikResult{
device_id: @device_uuid,
job_id: @job_uuid,
sentences: sentences,
timestamp: 1_234_567_890
}
encoded = MikrotikResult.encode(result)
assert {:error, _reason} = MikrotikResult.decode(encoded)
end
test "mikrotik result rejects too long error message" do
result = %MikrotikResult{
device_id: @device_uuid,
job_id: @job_uuid,
sentences: [],
error: String.duplicate("a", 1001),
timestamp: 1_234_567_890
}
encoded = MikrotikResult.encode(result)
assert {:error, _reason} = MikrotikResult.decode(encoded)
end
test "lldp topology result rejects too many neighbors" do
neighbors =
Enum.map(1..1001, fn i ->
%LldpNeighbor{
neighbor_name: "neighbor-#{i}",
local_port: "Eth1/0/1",
remote_port: "Gi0/1",
remote_port_id: "Gi0/1",
management_addresses: []
}
end)
result = %Towerops.Proto.Types.LldpTopologyResult{
device_id: @device_uuid,
job_id: @job_uuid,
local_system_name: "switch01",
neighbors: neighbors,
timestamp: 1_234_567_890
}
encoded = Encode.encode_lldp_topology_result(result)
assert {:error, _reason} = LldpTopologyResult.decode(encoded)
end
test "agent error rejects too long timestamp" do
error = %AgentError{
device_id: @device_uuid,
job_id: @job_uuid,
message: "Error message",
timestamp: 5_000_000_000
}
encoded = AgentError.encode(error)
assert {:error, _reason} = AgentError.decode(encoded)
end
test "decodes string field with wrong wire type" do
bin = <<0x08, 0x01>>
assert {:error, _reason} = AgentHeartbeat.decode(bin)
end
test "decodes uint field with wrong wire type" do
bin = <<0x1A, 0x01, 0x00>>
assert {:error, _reason} = AgentHeartbeat.decode(bin)
end
test "decodes int64 field with wrong wire type" do
bin = <<0x22, 0x01, 0x00>>
assert {:error, _reason} = AgentError.decode(bin)
end
test "decodes double field with wrong wire type" do
bin = <<0x18, 0x00>>
assert {:error, _reason} = MonitoringCheck.decode(bin)
end
test "decodes bool field with wrong wire type" do
bin = <<0x12, 0x01, 0x00>>
assert {:error, _reason} = CredentialTestResult.decode(bin)
end
test "decodes enum field with wrong wire type" do
bin = <<0x12, 0x01, 0x00>>
assert {:error, _reason} = AgentJob.decode(bin)
end
end
describe "HeartbeatResponse" do
test "encodes and decodes status" do
response = %HeartbeatResponse{status: "ok"}
encoded = HeartbeatResponse.encode(response)
decoded = HeartbeatResponse.decode(encoded)
assert decoded.status == response.status
end
test "encodes empty message" do
response = %HeartbeatResponse{}
encoded = HeartbeatResponse.encode(response)
decoded = HeartbeatResponse.decode(encoded)
assert decoded.status == ""
end
end
describe "HeartbeatMetadata" do
test "encodes and decodes" do
hb = %HeartbeatMetadata{
version: "1.0.0",
hostname: "agent-host",
uptime_seconds: 3600
}
encoded = HeartbeatMetadata.encode(hb)
decoded = HeartbeatMetadata.decode(encoded)
assert decoded.version == hb.version
assert decoded.hostname == hb.hostname
assert decoded.uptime_seconds == hb.uptime_seconds
end
test "encodes empty message" do
encoded = HeartbeatMetadata.encode(%HeartbeatMetadata{})
decoded = HeartbeatMetadata.decode(encoded)
assert decoded.version == ""
assert decoded.hostname == ""
assert decoded.uptime_seconds == 0
end
end
describe "Decode.decode_agent_config" do
defp full_agent_config do
%AgentConfig{
version: "1.0.0",
poll_interval_seconds: 300,
devices: [
%Device{
id: "device-1",
name: "core-switch",
ip_address: "192.168.1.1",
snmp: %Towerops.Proto.Types.SnmpConfig{
enabled: true,
version: "2c",
community: "public",
port: 161,
transport: "udp"
},
poll_interval_seconds: 300,
sensors: [
%Towerops.Proto.Types.Sensor{
id: "sensor-1",
sensor_type: "temperature",
oid: "1.3.6.1.4.1.14988.1.1.3.10.0",
divisor: 10.0,
unit: "celsius",
metadata: %{"location" => "CPU Core 1"}
}
],
interfaces: [
%Towerops.Proto.Types.Interface{
id: "if-1",
if_index: 1,
if_name: "GigabitEthernet0/1"
}
],
monitoring_enabled: true,
check_interval_seconds: 60
}
],
checks: [
%Check{
id: "check-1",
check_type: "http",
interval_seconds: 60,
timeout_ms: 5000,
config:
{:http,
%HttpCheckConfig{
url: "https://example.com/health",
method: "GET",
expected_status: 200,
verify_ssl: true,
headers: %{},
body: "",
regex: "",
follow_redirects: false
}}
}
]
}
end
test "encodes and decodes the config version and top-level structure" do
config = full_agent_config()
encoded = Encode.encode_agent_config(config)
assert {:ok, decoded} = Decode.decode_agent_config(encoded)
assert decoded.version == config.version
assert decoded.poll_interval_seconds == config.poll_interval_seconds
assert length(decoded.devices) == 1
assert length(decoded.checks) == 1
end
test "encodes and decodes device id, name, ip and snmp config" do
config = full_agent_config()
encoded = Encode.encode_agent_config(config)
assert {:ok, decoded} = Decode.decode_agent_config(encoded)
dev = hd(decoded.devices)
assert dev.id == "device-1"
assert dev.name == "core-switch"
assert dev.ip_address == "192.168.1.1"
assert dev.snmp.enabled == true
assert dev.snmp.version == "2c"
assert dev.snmp.community == "public"
assert dev.snmp.port == 161
end
test "encodes and decodes device sensors and interfaces" do
config = full_agent_config()
encoded = Encode.encode_agent_config(config)
assert {:ok, decoded} = Decode.decode_agent_config(encoded)
dev = hd(decoded.devices)
assert dev.poll_interval_seconds == 300
assert length(dev.sensors) == 1
assert hd(dev.sensors).id == "sensor-1"
assert hd(dev.sensors).sensor_type == "temperature"
assert hd(dev.sensors).divisor == 10.0
assert length(dev.interfaces) == 1
assert hd(dev.interfaces).if_index == 1
assert hd(dev.interfaces).if_name == "GigabitEthernet0/1"
assert dev.monitoring_enabled == true
assert dev.check_interval_seconds == 60
end
test "encodes and decodes the http check" do
config = full_agent_config()
encoded = Encode.encode_agent_config(config)
assert {:ok, decoded} = Decode.decode_agent_config(encoded)
chk = hd(decoded.checks)
assert chk.id == "check-1"
assert chk.check_type == "http"
assert chk.interval_seconds == 60
assert chk.timeout_ms == 5000
end
test "encodes and decodes config with empty devices and checks" do
config = %AgentConfig{
version: "2.0.0",
poll_interval_seconds: 120,
devices: [],
checks: []
}
encoded = Encode.encode_agent_config(config)
assert {:ok, decoded} = Decode.decode_agent_config(encoded)
assert decoded.version == "2.0.0"
assert decoded.poll_interval_seconds == 120
assert decoded.devices == []
assert decoded.checks == []
end
test "encodes and decodes config with device without snmp" do
config = %AgentConfig{
version: "1.0.0",
poll_interval_seconds: 300,
devices: [
%Device{
id: "device-nosnmp",
name: "non-snmp-device",
ip_address: "10.0.0.1",
snmp: nil,
poll_interval_seconds: 300,
sensors: [],
interfaces: [],
monitoring_enabled: false,
check_interval_seconds: 0
}
],
checks: []
}
encoded = Encode.encode_agent_config(config)
assert {:ok, decoded} = Decode.decode_agent_config(encoded)
assert length(decoded.devices) == 1
assert hd(decoded.devices).snmp == nil
assert hd(decoded.devices).monitoring_enabled == false
end
test "rejects wrong wire type for string field" do
# field 1 (version) expects wire type 2 (LEN), send wire type 0 (VARINT)
bin = <<0x08, 0x01>>
assert {:error, _reason} = Decode.decode_agent_config(bin)
end
end
describe "Decode.decode_agent_job_list" do
test "encodes and decodes a list of jobs" do
job_list = %Towerops.Proto.Types.AgentJobList{
jobs: [
%Towerops.Proto.Types.AgentJob{
job_id: "poll:device-1",
job_type: :poll,
device_id: "device-1",
snmp_device: %Towerops.Proto.Types.SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
v3_security_level: "",
v3_username: "",
v3_auth_protocol: "",
v3_auth_password: "",
v3_priv_protocol: "",
v3_priv_password: "",
transport: "udp"
},
queries: [
%Towerops.Proto.Types.SnmpQuery{
query_type: :get,
oids: ["1.3.6.1.2.1.1.1.0"]
}
],
mikrotik_device: nil,
mikrotik_commands: []
},
%Towerops.Proto.Types.AgentJob{
job_id: "discover:device-2",
job_type: :discover,
device_id: "device-2",
snmp_device: %Towerops.Proto.Types.SnmpDevice{
ip: "10.0.0.2",
community: "private",
version: "2c",
port: 161,
v3_security_level: "",
v3_username: "",
v3_auth_protocol: "",
v3_auth_password: "",
v3_priv_protocol: "",
v3_priv_password: "",
transport: ""
},
queries: [
%Towerops.Proto.Types.SnmpQuery{
query_type: :walk,
oids: ["1.3.6.1.2.1.2.2.1"]
}
],
mikrotik_device: nil,
mikrotik_commands: []
}
]
}
encoded = Encode.encode_agent_job_list(job_list)
assert {:ok, decoded} = Decode.decode_agent_job_list(encoded)
assert length(decoded.jobs) == 2
first = Enum.at(decoded.jobs, 0)
assert first.job_id == "poll:device-1"
assert first.job_type == :poll
assert first.device_id == "device-1"
assert first.snmp_device.ip == "192.168.1.1"
assert hd(first.queries).query_type == :get
second = Enum.at(decoded.jobs, 1)
assert second.job_id == "discover:device-2"
assert second.job_type == :discover
assert second.device_id == "device-2"
assert hd(second.queries).query_type == :walk
end
test "encodes and decodes empty job list" do
job_list = %Towerops.Proto.Types.AgentJobList{jobs: []}
encoded = Encode.encode_agent_job_list(job_list)
assert {:ok, decoded} = Decode.decode_agent_job_list(encoded)
assert decoded.jobs == []
end
test "encodes and decodes single job" do
job_list = %Towerops.Proto.Types.AgentJobList{
jobs: [
%Towerops.Proto.Types.AgentJob{
job_id: "single:device-1",
job_type: :poll,
device_id: "device-1",
snmp_device: nil,
queries: [],
mikrotik_device: nil,
mikrotik_commands: []
}
]
}
encoded = Encode.encode_agent_job_list(job_list)
assert {:ok, decoded} = Decode.decode_agent_job_list(encoded)
assert length(decoded.jobs) == 1
assert hd(decoded.jobs).job_id == "single:device-1"
end
test "rejects wrong wire type" do
bin = <<0x08, 0x01>>
assert {:error, _reason} = Decode.decode_agent_job_list(bin)
end
end
describe "Decode.decode_check_list" do
test "encodes and decodes a list of checks with http config" do
check_list = %CheckList{
checks: [
%Check{
id: "http-check-1",
check_type: "http",
interval_seconds: 60,
timeout_ms: 5000,
config:
{:http,
%HttpCheckConfig{
url: "https://example.com/ping",
method: "GET",
expected_status: 200,
verify_ssl: true,
headers: %{},
body: "",
regex: "OK",
follow_redirects: false
}}
}
]
}
encoded = Encode.encode_check_list(check_list)
assert {:ok, decoded} = Decode.decode_check_list(encoded)
assert length(decoded.checks) == 1
check = hd(decoded.checks)
assert check.id == "http-check-1"
assert check.check_type == "http"
assert check.interval_seconds == 60
assert check.timeout_ms == 5000
assert {:http, http_config} = check.config
assert http_config.url == "https://example.com/ping"
assert http_config.method == "GET"
assert http_config.expected_status == 200
assert http_config.verify_ssl == true
assert http_config.regex == "OK"
end
test "encodes and decodes checks with different config types" do
check_list = %CheckList{
checks: [
%Check{
id: "tcp-check-1",
check_type: "tcp",
interval_seconds: 120,
timeout_ms: 3000,
config:
{:tcp,
%Towerops.Proto.Types.TcpCheckConfig{
host: "smtp.example.com",
port: 25,
send: "",
expect: "220"
}}
},
%Check{
id: "dns-check-1",
check_type: "dns",
interval_seconds: 300,
timeout_ms: 5000,
config:
{:dns,
%Towerops.Proto.Types.DnsCheckConfig{
hostname: "example.com",
server: "8.8.8.8",
record_type: "A",
expected: "93.184.216.34"
}}
},
%Check{
id: "ssl-check-1",
check_type: "ssl",
interval_seconds: 86_400,
timeout_ms: 10_000,
config:
{:ssl,
%Towerops.Proto.Types.SslCheckConfig{
host: "example.com",
port: 443,
warning_days: 30
}}
}
]
}
encoded = Encode.encode_check_list(check_list)
assert {:ok, decoded} = Decode.decode_check_list(encoded)
assert length(decoded.checks) == 3
tcp = Enum.at(decoded.checks, 0)
assert tcp.id == "tcp-check-1"
assert {:tcp, tcp_conf} = tcp.config
assert tcp_conf.host == "smtp.example.com"
assert tcp_conf.port == 25
dns = Enum.at(decoded.checks, 1)
assert dns.id == "dns-check-1"
assert {:dns, dns_conf} = dns.config
assert dns_conf.hostname == "example.com"
assert dns_conf.server == "8.8.8.8"
ssl = Enum.at(decoded.checks, 2)
assert ssl.id == "ssl-check-1"
assert {:ssl, ssl_conf} = ssl.config
assert ssl_conf.host == "example.com"
assert ssl_conf.port == 443
assert ssl_conf.warning_days == 30
end
test "encodes and decodes empty check list" do
check_list = %CheckList{checks: []}
encoded = Encode.encode_check_list(check_list)
assert {:ok, decoded} = Decode.decode_check_list(encoded)
assert decoded.checks == []
end
test "encodes and decodes check with no_config" do
check_list = %CheckList{
checks: [
%Check{
id: "no-config-check",
check_type: "custom",
interval_seconds: 60,
timeout_ms: 1000,
config: :no_config
}
]
}
encoded = Encode.encode_check_list(check_list)
assert {:ok, decoded} = Decode.decode_check_list(encoded)
assert length(decoded.checks) == 1
assert hd(decoded.checks).config == :no_config
end
test "rejects wrong wire type" do
bin = <<0x08, 0x01>>
assert {:error, _reason} = Decode.decode_check_list(bin)
end
end
describe "Decode.decode_sensor" do
test "encodes and decodes sensor with metadata" do
sensor = %Towerops.Proto.Types.Sensor{
id: "sensor-cpu-temp",
sensor_type: "temperature",
oid: "1.3.6.1.4.1.14988.1.1.3.10.0",
divisor: 10.0,
unit: "celsius",
metadata: %{"location" => "CPU Core 1", "threshold" => "80"}
}
encoded = Encode.encode_sensor(sensor)
assert {:ok, decoded} = Decode.decode_sensor(encoded)
assert decoded.id == sensor.id
assert decoded.sensor_type == sensor.sensor_type
assert decoded.oid == sensor.oid
assert decoded.divisor == sensor.divisor
assert decoded.unit == sensor.unit
assert decoded.metadata == sensor.metadata
assert decoded.metadata["location"] == "CPU Core 1"
end
test "encodes and decodes sensor without metadata" do
sensor = %Towerops.Proto.Types.Sensor{
id: "sensor-voltage",
sensor_type: "voltage",
oid: "1.3.6.1.4.1.14988.1.1.3.8.0",
divisor: 1.0,
unit: "volts",
metadata: %{}
}
encoded = Encode.encode_sensor(sensor)
assert {:ok, decoded} = Decode.decode_sensor(encoded)
assert decoded.id == sensor.id
assert decoded.sensor_type == sensor.sensor_type
assert decoded.oid == sensor.oid
assert decoded.divisor == sensor.divisor
assert decoded.unit == sensor.unit
assert decoded.metadata == %{}
end
test "encodes and decodes empty sensor" do
sensor = %Towerops.Proto.Types.Sensor{
id: "",
sensor_type: "",
oid: "",
divisor: 0.0,
unit: "",
metadata: %{}
}
encoded = Encode.encode_sensor(sensor)
assert {:ok, decoded} = Decode.decode_sensor(encoded)
assert decoded.id == ""
assert decoded.sensor_type == ""
assert decoded.oid == ""
assert decoded.divisor == 0.0
assert decoded.unit == ""
assert decoded.metadata == %{}
end
test "encodes and decodes sensor with zero divisor" do
sensor = %Towerops.Proto.Types.Sensor{
id: "sensor-unscaled",
sensor_type: "counter",
oid: "1.3.6.1.2.1.2.2.1.10",
divisor: 0.0,
unit: "bytes",
metadata: %{}
}
encoded = Encode.encode_sensor(sensor)
assert {:ok, decoded} = Decode.decode_sensor(encoded)
assert decoded.divisor == 0.0
end
test "rejects wrong wire type for string field" do
# field 1 (id) expects wire type 2 (LEN), send wire type 0 (VARINT)
bin = <<0x08, 0x01>>
assert {:error, _reason} = Decode.decode_sensor(bin)
end
end
describe "Decode.decode_monitoring_check" do
test "encodes and decodes success check" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 45.2,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:ok, decoded} = Decode.decode_monitoring_check(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
assert decoded.response_time_ms == check.response_time_ms
assert decoded.timestamp == check.timestamp
end
test "encodes and decodes failure check" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: "failure",
response_time_ms: 0.0,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:ok, decoded} = Decode.decode_monitoring_check(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
assert decoded.response_time_ms == check.response_time_ms
assert decoded.timestamp == check.timestamp
end
test "decodes empty device_id as error" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: "",
status: "success",
response_time_ms: 10.0,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "decodes invalid device_id format as error" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: "not-a-uuid",
status: "success",
response_time_ms: 10.0,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "rejects status string that is too long" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: String.duplicate("a", 256),
response_time_ms: 10.0,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "rejects negative response time" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: -1.0,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "rejects response time that is too large" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 60_001,
timestamp: 1_234_567_890
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "rejects negative timestamp" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 10.0,
timestamp: -1
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "rejects timestamp that is too large" do
check = %Towerops.Proto.Types.MonitoringCheck{
device_id: @device_uuid,
status: "success",
response_time_ms: 10.0,
timestamp: 2_147_483_648
}
encoded = Encode.encode_monitoring_check(check)
assert {:error, _reason} = Decode.decode_monitoring_check(encoded)
end
test "rejects wrong wire type for string field" do
# field 1 (device_id) expects wire type 2 (LEN), send wire type 0 (VARINT)
bin = <<0x08, 0x01>>
assert {:error, _reason} = Decode.decode_monitoring_check(bin)
end
test "rejects wrong wire type for double field" do
# field 3 (response_time_ms) expects wire type 1 (64-bit), send wire type 0 (VARINT)
bin = <<0x18, 0x00>>
assert {:error, _reason} = Decode.decode_monitoring_check(bin)
end
end
end