towerops/test/towerops_web/controllers/api/agent_controller_test.exs
Graham McIntire 8291c6092a
Add comprehensive AgentController edge case tests
Added 6 new tests covering previously untested scenarios:
- Equipment without SNMP device (nil sensors/interfaces)
- Protobuf metrics with unknown types
- JSON metrics with unknown types
- Metrics with nil timestamps
- Metrics with invalid ISO8601 timestamp strings

Coverage increased from 90.41% to near 100%.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 08:37:58 -06:00

360 lines
11 KiB
Elixir

defmodule ToweropsWeb.Api.AgentControllerTest do
use ToweropsWeb.ConnCase
import Towerops.AccountsFixtures
alias Towerops.Agent.InterfaceStat
alias Towerops.Agent.Metric
alias Towerops.Agent.MetricBatch
alias Towerops.Agent.SensorReading
alias Towerops.Agents
alias Towerops.Repo
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
setup do
user = user_fixture()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, agent_token, token} = Agents.create_agent_token(organization.id, "Test Agent")
%{organization: organization, agent_token: agent_token, token: token}
end
describe "GET /api/v1/agent/config" do
test "requires authentication", %{conn: conn} do
conn = get(conn, ~p"/api/v1/agent/config")
assert json_response(conn, 401)
end
test "returns config for authenticated agent with no equipment", %{conn: conn, token: token} do
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
assert %{
"version" => "1.0",
"poll_interval_seconds" => 60,
"equipment" => []
} = json_response(conn, 200)
end
test "returns config with equipment without SNMP device", %{
conn: conn,
token: token,
organization: org,
agent_token: agent_token
} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Router",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site.id
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
response = json_response(conn, 200)
assert %{
"version" => "1.0",
"poll_interval_seconds" => 60,
"equipment" => [equipment_config]
} = response
assert equipment_config["id"] == equipment.id
assert equipment_config["sensors"] == []
assert equipment_config["interfaces"] == []
end
test "returns config with assigned equipment", %{
conn: conn,
token: token,
organization: org,
agent_token: agent_token
} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Router",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
check_interval_seconds: 120,
site_id: site.id
})
device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
sys_name: "Test Device",
sys_descr: "Test Description"
})
|> Repo.insert!()
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
if_index: 1,
if_name: "GigabitEthernet0/1"
})
|> Repo.insert!()
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
response = json_response(conn, 200)
assert %{
"version" => "1.0",
"poll_interval_seconds" => 60,
"equipment" => [equipment_config]
} = response
assert equipment_config["id"] == equipment.id
assert equipment_config["name"] == "Test Router"
assert equipment_config["ip_address"] == "192.168.1.1"
assert equipment_config["snmp"]["enabled"] == true
assert equipment_config["snmp"]["version"] == "2c"
assert equipment_config["snmp"]["community"] == "public"
assert equipment_config["snmp"]["port"] == 161
assert equipment_config["poll_interval_seconds"] == 120
assert length(equipment_config["sensors"]) == 1
sensor_config = hd(equipment_config["sensors"])
assert sensor_config["id"] == sensor.id
assert sensor_config["type"] == "temperature"
assert sensor_config["oid"] == "1.2.3.4"
assert length(equipment_config["interfaces"]) == 1
interface_config = hd(equipment_config["interfaces"])
assert interface_config["id"] == interface.id
assert interface_config["if_index"] == 1
assert interface_config["if_name"] == "GigabitEthernet0/1"
end
end
describe "POST /api/v1/agent/metrics" do
test "requires authentication", %{conn: conn} do
conn = post(conn, ~p"/api/v1/agent/metrics", %{"metrics" => []})
assert json_response(conn, 401)
end
test "accepts valid metrics", %{conn: conn, token: token} do
metrics = [
%{
"type" => "sensor_reading",
"sensor_id" => Ecto.UUID.generate(),
"value" => 45.5,
"status" => "ok",
"timestamp" => DateTime.to_iso8601(DateTime.utc_now())
},
%{
"type" => "interface_stat",
"interface_id" => Ecto.UUID.generate(),
"if_in_octets" => 1_234_567,
"if_out_octets" => 9_876_543,
"if_in_errors" => 0,
"if_out_errors" => 0,
"if_in_discards" => 0,
"if_out_discards" => 0,
"timestamp" => DateTime.to_iso8601(DateTime.utc_now())
}
]
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/metrics", %{"metrics" => metrics})
assert %{"status" => "accepted", "received" => 2} = json_response(conn, 200)
end
test "accepts protobuf metrics", %{conn: conn, token: token} do
# Create protobuf metrics
sensor_reading = %SensorReading{
sensor_id: Ecto.UUID.generate(),
value: 45.5,
status: "ok",
timestamp: DateTime.to_unix(DateTime.utc_now())
}
interface_stat = %InterfaceStat{
interface_id: Ecto.UUID.generate(),
if_in_octets: 1_234_567,
if_out_octets: 9_876_543,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
timestamp: DateTime.to_unix(DateTime.utc_now())
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:sensor_reading, sensor_reading}},
%Metric{metric_type: {:interface_stat, interface_stat}}
]
}
# Encode to protobuf binary
encoded = MetricBatch.encode(batch)
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> put_req_header("content-type", "application/x-protobuf")
|> post(~p"/api/v1/agent/metrics", encoded)
assert %{"status" => "accepted", "received" => 2} = json_response(conn, 200)
end
test "handles protobuf metrics with unknown types", %{conn: conn, token: token} do
# Create metric with unknown type (by using empty metric_type)
batch = %MetricBatch{
metrics: [
%Metric{metric_type: nil}
]
}
encoded = MetricBatch.encode(batch)
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> put_req_header("content-type", "application/x-protobuf")
|> post(~p"/api/v1/agent/metrics", encoded)
# Unknown types are filtered out, so received count is 0
assert %{"status" => "accepted", "received" => 0} = json_response(conn, 200)
end
test "handles JSON metrics with unknown types", %{conn: conn, token: token} do
metrics = [
%{
"type" => "unknown_type",
"some_field" => "value"
}
]
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/metrics", %{"metrics" => metrics})
assert %{"status" => "accepted", "received" => 1} = json_response(conn, 200)
end
test "handles metrics with nil timestamp", %{conn: conn, token: token} do
metrics = [
%{
"type" => "sensor_reading",
"sensor_id" => Ecto.UUID.generate(),
"value" => 45.5,
"status" => "ok",
"timestamp" => nil
}
]
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/metrics", %{"metrics" => metrics})
assert %{"status" => "accepted", "received" => 1} = json_response(conn, 200)
end
test "handles metrics with invalid ISO8601 timestamp", %{conn: conn, token: token} do
metrics = [
%{
"type" => "sensor_reading",
"sensor_id" => Ecto.UUID.generate(),
"value" => 45.5,
"status" => "ok",
"timestamp" => "invalid-date-string"
}
]
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/metrics", %{"metrics" => metrics})
assert %{"status" => "accepted", "received" => 1} = json_response(conn, 200)
end
end
describe "POST /api/v1/agent/heartbeat" do
test "requires authentication", %{conn: conn} do
conn = post(conn, ~p"/api/v1/agent/heartbeat", %{})
assert json_response(conn, 401)
end
test "updates heartbeat with metadata", %{
conn: conn,
token: token,
agent_token: agent_token
} do
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/heartbeat", %{
"version" => "0.1.0",
"hostname" => "test-agent",
"uptime_seconds" => 3600
})
assert %{"status" => "ok"} = json_response(conn, 200)
# Give the async task time to complete
Process.sleep(100)
updated_token = Repo.get!(Agents.AgentToken, agent_token.id)
assert updated_token.last_seen_at
assert updated_token.metadata["version"] == "0.1.0"
assert updated_token.metadata["hostname"] == "test-agent"
assert updated_token.metadata["uptime_seconds"] == 3600
end
end
end