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