towerops/test/towerops_web/controllers/api/agent_controller_test.exs
Graham McIntire a2d96f8e6e
Implement hierarchical agent assignment for SNMP polling
Add three-level agent assignment hierarchy (Equipment > Site > Organization)
allowing flexible agent deployment strategies. Agents now receive only equipment
assigned to them through direct assignment or inheritance from site/organization
defaults.

Key changes:
- Add agent_token_id to Sites table with migration
- Implement get_effective_agent_token/1 for hierarchical resolution
- Add list_agent_polling_targets/1 to return polling targets per agent
- Update API config endpoint to use hierarchical polling targets
- Add agent assignment UI to equipment, site, and organization forms
- Show agent source (direct/site/org/none) in equipment form
- Add equipment count column to agent list
- Update terminology from "poll from server" to "cloud polling"

Tests:
- Add 8 comprehensive tests for list_agent_polling_targets/1
- Add end-to-end test for hierarchical config endpoint
- All 775 tests passing
2026-01-14 08:38:50 -06:00

458 lines
14 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
test "returns equipment with hierarchical agent assignment", %{
conn: conn,
token: token,
organization: org,
agent_token: agent_token
} do
# Create another agent
{:ok, other_agent, _other_token} =
Agents.create_agent_token(org.id, "Other Agent")
# Set organization default to other_agent
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{
default_agent_token_id: other_agent.id
})
# Create site1 with no agent (will inherit org default)
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Site 1",
organization_id: org.id
})
# Create site2 with explicit agent assignment to our agent
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: org.id,
agent_token_id: agent_token.id
})
# Create site3 with no agent (will inherit org default)
{:ok, site3} =
Towerops.Sites.create_site(%{
name: "Site 3",
organization_id: org.id
})
# Equipment 1: Directly assigned to our agent (highest priority)
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site1.id
})
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
# Equipment 2: At site2, inherits from site (our agent)
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site2.id
})
# Equipment 3: At site3, inherits from org default (other agent) - should NOT be returned
{:ok, _equipment3} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 3",
ip_address: "192.168.1.3",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site3.id
})
# Equipment 4: SNMP disabled - should NOT be returned
{:ok, _equipment4} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 4",
ip_address: "192.168.1.4",
snmp_enabled: false,
site_id: site1.id
})
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
response = json_response(conn, 200)
assert %{"equipment" => equipment_list} = response
assert length(equipment_list) == 2
# Verify equipment IDs match
equipment_ids = Enum.map(equipment_list, & &1["id"])
assert equipment1.id in equipment_ids
assert equipment2.id in equipment_ids
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