towerops/test/towerops/agents_test.exs
Graham McIntire 933bfafeb0 Fix DB pool exhaustion and settings page crashes (#88)
## Summary
- **DB pool exhaustion**: `device_has_effective_agent?` was doing 2 DB queries per call (3-table join + global settings lookup), called 2-3x per worker cycle across hundreds of devices (~6000 unnecessary queries/minute). Added an ETS-backed `AgentCache` with 120s TTL and removed redundant calls within the same worker cycle.
- **Settings page crash**: `KeyError: key :user_agent not found` on BrowserSession — field doesn't exist, replaced with `device_type`.
- **Login history IP truncation**: Changed from side-by-side grid to stacked table layout so IP addresses aren't clipped.

## Test plan
- [x] 8 new tests for AgentCache (cache hit, miss, invalidation, global default)
- [x] Full test suite passes (8651 tests, 0 failures, verified across multiple runs)
- [x] `mix format` and pre-commit hooks pass

Reviewed-on: graham/towerops-web#88
2026-03-19 15:16:34 -05:00

1983 lines
66 KiB
Elixir

defmodule Towerops.AgentsTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
alias Towerops.Admin.AuditLog
alias Towerops.Agents
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentCache
alias Towerops.Agents.AgentToken
setup do
user = user_fixture()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization, user: user}
end
describe "create_agent_token/2" do
test "creates agent token with valid organization and name", %{organization: org} do
assert {:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent")
assert is_binary(token)
assert byte_size(token) > 0
assert agent_token.organization_id == org.id
assert agent_token.name == "Test Agent"
assert agent_token.enabled == true
assert is_binary(agent_token.token)
assert agent_token.token == token
end
test "returns error with invalid data" do
assert {:error, changeset} = Agents.create_agent_token(nil, "")
refute changeset.valid?
end
end
describe "list_organization_agent_tokens/1" do
test "returns all agent tokens for organization", %{organization: org1, user: user} do
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id, bypass_limits: true)
{:ok, token1, _} = Agents.create_agent_token(org1.id, "Agent 1")
{:ok, token2, _} = Agents.create_agent_token(org1.id, "Agent 2")
{:ok, _token3, _} = Agents.create_agent_token(org2.id, "Agent 3")
tokens = Agents.list_organization_agent_tokens(org1.id)
assert length(tokens) == 2
assert Enum.any?(tokens, &(&1.id == token1.id))
assert Enum.any?(tokens, &(&1.id == token2.id))
end
test "returns empty list for organization with no tokens", %{organization: org} do
assert Agents.list_organization_agent_tokens(org.id) == []
end
end
describe "verify_agent_token/1" do
test "verifies valid token", %{organization: org} do
{:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, verified_token} = Agents.verify_agent_token(token)
assert verified_token.id == agent_token.id
end
test "fails for invalid token" do
assert {:error, :invalid_token} = Agents.verify_agent_token("invalid_token")
end
test "fails for revoked token", %{organization: org} do
{:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, _} = Agents.revoke_agent_token(agent_token.id)
assert {:error, :invalid_token} = Agents.verify_agent_token(token)
end
end
describe "revoke_agent_token/1" do
test "disables agent token", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, revoked_token} = Agents.revoke_agent_token(agent_token.id)
assert revoked_token.enabled == false
end
end
describe "update_agent_token/2" do
test "updates agent token name", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Old Name")
assert {:ok, updated_token} = Agents.update_agent_token(agent_token, %{name: "New Name"})
assert updated_token.name == "New Name"
assert updated_token.id == agent_token.id
end
test "returns error with invalid data", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Name")
assert {:error, changeset} = Agents.update_agent_token(agent_token, %{name: ""})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "does not update other fields", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Name")
original_token = agent_token.token
{:ok, updated_token} =
Agents.update_agent_token(agent_token, %{name: "Updated Name", token: "hacked"})
assert updated_token.name == "Updated Name"
assert updated_token.token == original_token
end
end
describe "delete_agent_token/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
%{site: site, agent_token: agent_token}
end
test "deletes agent token from database", %{agent_token: agent_token, organization: _organization} do
assert {:ok, _} = Agents.delete_agent_token(agent_token.id)
assert_raise Ecto.NoResultsError, fn ->
Agents.get_agent_token!(agent_token.id)
end
end
test "removes all direct device assignments", %{
site: site,
agent_token: agent_token,
organization: organization
} do
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
# Assign both devices to the agent
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device2.id)
assert Agents.count_assigned_devices(agent_token.id) == 2
# Delete the agent
assert {:ok, _} = Agents.delete_agent_token(agent_token.id)
# Verify assignments are removed
assert Agents.get_device_assignment(device1.id) == nil
assert Agents.get_device_assignment(device2.id) == nil
end
test "devices fall back to site agent when available", %{
organization: org,
site: site,
agent_token: agent_token_to_delete
} do
# Create another agent that will be the site default
{:ok, site_agent, _} = Agents.create_agent_token(org.id, "Site Agent")
# Set site to use the site_agent
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: site_agent.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
# Directly assign device to agent_to_delete (overriding site default)
{:ok, _} = Agents.assign_device_to_agent(agent_token_to_delete.id, device.id)
# Device should currently use agent_to_delete
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == agent_token_to_delete.id
# Delete the agent
assert {:ok, _} = Agents.delete_agent_token(agent_token_to_delete.id)
# Device should now fall back to site agent
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == site_agent.id
end
test "devices fall back to organization default when available", %{
organization: org,
site: site,
agent_token: agent_token_to_delete
} do
# Create another agent that will be the org default
{:ok, org_agent, _} = Agents.create_agent_token(org.id, "Org Agent")
# Set organization to use the org_agent
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: org_agent.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
# Directly assign device to agent_to_delete (overriding org default)
{:ok, _} = Agents.assign_device_to_agent(agent_token_to_delete.id, device.id)
# Device should currently use agent_to_delete
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == agent_token_to_delete.id
# Delete the agent
assert {:ok, _} = Agents.delete_agent_token(agent_token_to_delete.id)
# Device should now fall back to org default
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == org_agent.id
end
test "devices have no agent when no fallback exists (cloud polling)", %{
organization: organization,
site: site,
agent_token: agent_token
} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# Directly assign device to agent (no site or org defaults)
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device.id)
# Device should currently use the agent
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == agent_token.id
# Delete the agent
assert {:ok, _} = Agents.delete_agent_token(agent_token.id)
# Device should now have no agent (cloud polling)
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == nil
end
test "removes agent from site defaults when deleting", %{
organization: org,
site: site,
agent_token: agent_token
} do
# Set site to use this agent
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent_token.id})
# Create another agent as fallback
{:ok, fallback_agent, _} = Agents.create_agent_token(org.id, "Fallback Agent")
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: fallback_agent.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
# Device should use site agent
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == agent_token.id
# Delete the agent
assert {:ok, _} = Agents.delete_agent_token(agent_token.id)
# Site should no longer have this agent
site = Repo.reload!(site)
assert site.agent_token_id == nil
# Device should fall back to org default
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == fallback_agent.id
end
test "removes agent from organization defaults when deleting", %{
organization: org,
site: site,
agent_token: agent_token
} do
# Set organization to use this agent
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent_token.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
# Device should use org default
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == agent_token.id
# Delete the agent
assert {:ok, _} = Agents.delete_agent_token(agent_token.id)
# Organization should no longer have this agent as default
org = Repo.reload!(org)
assert org.default_agent_token_id == nil
# Device should have no agent
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == nil
end
end
describe "update_agent_token_heartbeat/3" do
test "updates last_seen_at and metadata", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
metadata = %{version: "0.1.0", hostname: "test-host"}
{1, _} = Agents.update_agent_token_heartbeat(agent_token.id, "192.168.1.1", metadata)
updated_token = Repo.get!(AgentToken, agent_token.id)
assert updated_token.last_ip == "192.168.1.1"
assert updated_token.metadata["version"] == "0.1.0"
assert updated_token.metadata["hostname"] == "test-host"
assert updated_token.last_seen_at
end
end
describe "assign_device_to_agent/2" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
%{site: site, device: device}
end
test "assigns equipment to agent", %{organization: org, device: device} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, assignment} =
Agents.assign_device_to_agent(agent_token.id, device.id)
assert assignment.agent_token_id == agent_token.id
assert assignment.device_id == device.id
assert assignment.enabled == true
end
test "prevents assigning equipment to multiple agents", %{
organization: org,
device: device
} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device.id)
assert {:error, changeset} = Agents.assign_device_to_agent(agent2.id, device.id)
assert "has already been taken" in errors_on(changeset).device_id
end
end
describe "unassign_device/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
%{site: site, device: device, agent_token: agent_token}
end
test "removes equipment assignment", %{agent_token: agent_token, device: device, organization: _organization} do
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
{1, _} = Agents.unassign_device(device.id)
assert Repo.get_by(AgentAssignment, device_id: device.id) == nil
end
end
describe "get_agent_token!/1" do
test "returns agent token by id", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
retrieved_token = Agents.get_agent_token!(agent_token.id)
assert retrieved_token.id == agent_token.id
assert retrieved_token.name == "Test Agent"
end
test "raises when token not found" do
assert_raise Ecto.NoResultsError, fn ->
Agents.get_agent_token!(Ecto.UUID.generate())
end
end
end
describe "update_agent_token_heartbeat/3 edge cases" do
test "updates without metadata when nil is provided", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{1, _} = Agents.update_agent_token_heartbeat(agent_token.id, "192.168.1.1", nil)
updated_token = Repo.get!(AgentToken, agent_token.id)
assert updated_token.last_ip == "192.168.1.1"
assert updated_token.last_seen_at
end
test "updates without metadata when empty map is provided", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{1, _} = Agents.update_agent_token_heartbeat(agent_token.id, "192.168.1.1", %{})
updated_token = Repo.get!(AgentToken, agent_token.id)
assert updated_token.last_ip == "192.168.1.1"
assert updated_token.last_seen_at
end
end
describe "get_device_assignment/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
%{site: site, device: device, agent_token: agent_token}
end
test "returns assignment when equipment is assigned", %{
agent_token: agent_token,
device: device
} do
{:ok, assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
retrieved_assignment = Agents.get_device_assignment(device.id)
assert retrieved_assignment.id == assignment.id
end
test "returns nil when equipment is not assigned", %{device: device, organization: _organization} do
assert Agents.get_device_assignment(device.id) == nil
end
end
describe "update_device_assignment/2" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, agent_token1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent_token2, _} = Agents.create_agent_token(org.id, "Agent 2")
%{
site: site,
device: device,
agent_token1: agent_token1,
agent_token2: agent_token2
}
end
test "creates new assignment when equipment is unassigned", %{
device: device,
agent_token1: agent_token1
} do
assert {:ok, assignment} =
Agents.update_device_assignment(device.id, agent_token1.id)
assert assignment.agent_token_id == agent_token1.id
assert assignment.device_id == device.id
end
test "updates existing assignment to new agent", %{
device: device,
agent_token1: agent_token1,
agent_token2: agent_token2
} do
{:ok, _} = Agents.assign_device_to_agent(agent_token1.id, device.id)
assert {:ok, updated_assignment} =
Agents.update_device_assignment(device.id, agent_token2.id)
assert updated_assignment.agent_token_id == agent_token2.id
assert updated_assignment.device_id == device.id
end
test "removes assignment when set to nil", %{
device: device,
agent_token1: agent_token1
} do
{:ok, _} = Agents.assign_device_to_agent(agent_token1.id, device.id)
assert {:ok, nil} = Agents.update_device_assignment(device.id, nil)
assert Agents.get_device_assignment(device.id) == nil
end
end
describe "list_agent_devices/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Test Equipment 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Test Equipment 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: org.id
})
{:ok, device3} =
Towerops.Devices.create_device(%{
name: "Test Equipment 3",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: org.id
})
%{site: site, device1: device1, device2: device2, device3: device3}
end
test "returns devices assigned to agent with preloads", %{
organization: org,
device1: device1,
device2: device2,
device3: device3
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device2.id)
device_list = Agents.list_agent_devices(agent_token.id)
assert length(device_list) == 2
assert Enum.any?(device_list, &(&1.id == device1.id))
assert Enum.any?(device_list, &(&1.id == device2.id))
refute Enum.any?(device_list, &(&1.id == device3.id))
# Check preloads
device = hd(device_list)
assert Ecto.assoc_loaded?(device.site)
end
end
describe "get_effective_agent_token/1 - hierarchical resolution" do
setup %{organization: org} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, agent3, _} = Agents.create_agent_token(org.id, "Agent 3")
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
%{
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
device: device
}
end
test "returns devices-level assignment (highest priority)", %{
organization: org,
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
device: device
} do
# Set up hierarchy: org default, site override, equipment override
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
{:ok, _} = Agents.assign_device_to_agent(agent3.id, device.id)
# Preload necessary associations
device = Repo.preload(device, site: [organization: :default_agent_token])
# Equipment assignment should take precedence
assert Agents.get_effective_agent_token(device) == agent3.id
end
test "returns site-level assignment when equipment not assigned", %{
organization: org,
agent1: agent1,
agent2: agent2,
site: site,
device: device
} do
# Set up hierarchy: org default and site override, but no equipment assignment
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
# Preload necessary associations
device = Repo.preload(device, site: [organization: :default_agent_token])
# Site assignment should be used
assert Agents.get_effective_agent_token(device) == agent2.id
end
test "returns organization-level assignment when site and equipment not assigned", %{
organization: org,
agent1: agent1,
device: device
} do
# Set up hierarchy: only org default
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
# Preload necessary associations
device = Repo.preload(device, site: [organization: :default_agent_token])
# Organization default should be used
assert Agents.get_effective_agent_token(device) == agent1.id
end
test "returns nil when no agent assigned at any level", %{device: device, organization: _organization} do
# No assignments at any level
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(device) == nil
end
test "returns global default cloud poller when no other assignment exists" do
# Create a cloud poller (application-wide agent)
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Global Cloud Poller")
# Set it as the global default
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
# Create a fresh organization and device with no assignments
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site 2",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment 2",
ip_address: "192.168.2.1",
site_id: site.id,
organization_id: org.id
})
device = Repo.preload(device, site: [organization: :default_agent_token])
# Should use global default cloud poller
assert Agents.get_effective_agent_token(device) == cloud_poller.id
# Clean up
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
end
test "organization default takes precedence over global default", %{
organization: org,
agent1: agent1,
device: device
} do
# Create a cloud poller as global default
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Global Cloud Poller")
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
# Set organization default
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
device = Repo.preload(device, site: [organization: :default_agent_token])
# Should use organization default, not global default
assert Agents.get_effective_agent_token(device) == agent1.id
# Clean up
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
end
end
describe "get_effective_agent_token_with_source/1" do
setup %{organization: org} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, agent3, _} = Agents.create_agent_token(org.id, "Agent 3")
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
%{
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
device: device
}
end
test "returns devices source for device-level assignment", %{
agent3: agent3,
device: device
} do
{:ok, _} = Agents.assign_device_to_agent(agent3.id, device.id)
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :device} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == agent3.id
end
test "returns site source for site-level assignment", %{
agent2: agent2,
site: site,
device: device
} do
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :site} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == agent2.id
end
test "returns organization source for organization-level assignment", %{
organization: org,
agent1: agent1,
device: device
} do
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :organization} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == agent1.id
end
test "returns none source when no assignment", %{device: device, organization: _organization} do
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {nil, :none} = Agents.get_effective_agent_token_with_source(device)
end
test "returns global source for global default cloud poller" do
# Create a cloud poller and set as global default
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Global Cloud Poller")
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
# Create a fresh organization and device with no assignments
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org 3"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site 3",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment 3",
ip_address: "192.168.3.1",
site_id: site.id,
organization_id: org.id
})
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :global} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == cloud_poller.id
# Clean up
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
end
end
describe "count_assigned_devices/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Test Equipment 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Test Equipment 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: org.id
})
{:ok, device3} =
Towerops.Devices.create_device(%{
name: "Test Equipment 3",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: org.id
})
%{site: site, device1: device1, device2: device2, device3: device3}
end
test "counts directly assigned equipment", %{
organization: org,
device1: device1,
device2: device2
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
# Initially no assignments
assert Agents.count_assigned_devices(agent_token.id) == 0
# Assign two pieces of equipment
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device1.id)
assert Agents.count_assigned_devices(agent_token.id) == 1
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device2.id)
assert Agents.count_assigned_devices(agent_token.id) == 2
end
test "does not count disabled assignments", %{
organization: org,
device1: device1
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, assignment} = Agents.assign_device_to_agent(agent_token.id, device1.id)
assert Agents.count_assigned_devices(agent_token.id) == 1
# Disable the assignment
assignment
|> Ecto.Changeset.change(enabled: false)
|> Repo.update!()
assert Agents.count_assigned_devices(agent_token.id) == 0
end
end
describe "list_agent_polling_targets/1 - hierarchical polling" do
setup %{organization: org} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, agent3, _} = Agents.create_agent_token(org.id, "Agent 3")
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Site 1",
organization_id: org.id
})
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: org.id
})
%{
agent1: agent1,
agent2: agent2,
agent3: agent3,
site1: site1,
site2: site2
}
end
test "returns device with direct agent assignment", %{
organization: organization,
agent1: agent1,
agent2: agent2,
site1: site1
} do
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Assign equipment to agent1
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device2.id)
# Agent1 should see both equipment
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "returns device inheriting from site agent", %{
organization: organization,
agent1: agent1,
agent2: agent2,
site1: site1
} do
# Set site1 to use agent1
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent1.id})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent1 should see both equipment (inherited from site)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "returns device inheriting from organization default", %{
organization: org,
agent1: agent1,
agent2: agent2,
site1: site1
} do
# Set organization default to agent1
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
organization_id: org.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent1 should see both equipment (inherited from organization)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "equipment-level assignment overrides site and organization", %{
organization: org,
agent1: agent1,
agent2: agent2,
agent3: agent3,
site1: site1
} do
# Set org default to agent1, site to agent2, equipment to agent3
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent2.id})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Directly assign to agent3
{:ok, _} = Agents.assign_device_to_agent(agent3.id, device1.id)
# Only agent3 should see the equipment
targets = Agents.list_agent_polling_targets(agent3.id)
assert length(targets) == 1
assert hd(targets).id == device1.id
# Agent1 and agent2 should not see it
assert Agents.list_agent_polling_targets(agent1.id) == []
assert Agents.list_agent_polling_targets(agent2.id) == []
end
test "site-level assignment overrides organization default", %{
organization: org,
agent1: agent1,
agent2: agent2,
site1: site1
} do
# Set org default to agent1, site to agent2
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent2.id})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent2 should see the equipment (site level)
targets = Agents.list_agent_polling_targets(agent2.id)
assert length(targets) == 1
assert hd(targets).id == device1.id
# Agent1 should not see it (org default is overridden by site)
assert Agents.list_agent_polling_targets(agent1.id) == []
end
test "only returns SNMP-enabled devices", %{
organization: organization,
agent1: agent1,
site1: site1
} do
# Equipment with SNMP enabled
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Equipment with SNMP disabled
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: false
})
# Assign both to agent1
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device2.id)
# Agent1 should only see SNMP-enabled devices
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 1
assert hd(targets).id == device1.id
assert hd(targets).snmp_enabled == true
end
test "returns device from multiple sites with different assignment levels", %{
organization: org,
agent1: agent1,
agent2: agent2,
site1: site1,
site2: site2
} do
# Set org default to agent1
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
# Site2 overrides to agent2
{:ok, _site} = Towerops.Sites.update_site(site2, %{agent_token_id: agent2.id})
# Equipment at site1 (inherits org default - agent1)
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Equipment at site2 (inherits site default - agent2)
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site2.id,
organization_id: org.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent1 should see device1 (from site1)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 1
assert hd(targets).id == device1.id
# Agent2 should see device2 (from site2)
targets = Agents.list_agent_polling_targets(agent2.id)
assert length(targets) == 1
assert hd(targets).id == device2.id
end
test "preloads necessary associations for API response", %{
organization: organization,
agent1: agent1,
site1: site1
} do
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device1.id)
targets = Agents.list_agent_polling_targets(agent1.id)
device = hd(targets)
# Verify preloads
assert Ecto.assoc_loaded?(device.site)
assert Ecto.assoc_loaded?(device.site.organization)
end
end
describe "create_cloud_poller/1" do
test "creates cloud poller with no organization" do
assert {:ok, agent_token, token} = Agents.create_cloud_poller("Cloud Poller 1")
assert is_binary(token)
assert byte_size(token) > 0
assert agent_token.organization_id == nil
assert agent_token.name == "Cloud Poller 1"
assert agent_token.enabled == true
assert agent_token.is_cloud_poller == true
assert is_binary(agent_token.token)
assert agent_token.token == token
end
test "returns error with empty name" do
assert {:error, changeset} = Agents.create_cloud_poller("")
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
end
describe "list_cloud_pollers/0" do
test "returns all cloud pollers ordered by created date" do
{:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1")
{:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2")
cloud_pollers = Agents.list_cloud_pollers()
assert length(cloud_pollers) == 2
# Verify both cloud pollers are in the list
cloud_ids = Enum.map(cloud_pollers, & &1.id)
assert cloud1.id in cloud_ids
assert cloud2.id in cloud_ids
end
test "returns empty list when no cloud pollers exist" do
assert Agents.list_cloud_pollers() == []
end
test "does not include organization agents" do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, _org_agent, _} = Agents.create_agent_token(org.id, "Org Agent")
{:ok, cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller")
cloud_pollers = Agents.list_cloud_pollers()
assert length(cloud_pollers) == 1
assert hd(cloud_pollers).id == cloud_agent.id
assert hd(cloud_pollers).is_cloud_poller == true
end
end
describe "list_organization_agent_tokens/1 - cloud poller exclusion" do
test "excludes cloud pollers from organization agents", %{organization: org} do
{:ok, org_agent1, _} = Agents.create_agent_token(org.id, "Org Agent 1")
{:ok, org_agent2, _} = Agents.create_agent_token(org.id, "Org Agent 2")
{:ok, _cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller")
org_agents = Agents.list_organization_agent_tokens(org.id)
assert length(org_agents) == 2
assert Enum.any?(org_agents, &(&1.id == org_agent1.id))
assert Enum.any?(org_agents, &(&1.id == org_agent2.id))
assert Enum.all?(org_agents, &(&1.is_cloud_poller == false))
end
end
describe "list_all_agent_tokens/0" do
test "returns all agent tokens across organizations and cloud pollers", %{
organization: org1,
user: user
} do
{:ok, org2} =
Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id, bypass_limits: true)
{:ok, token1, _} = Agents.create_agent_token(org1.id, "Org1 Agent")
{:ok, token2, _} = Agents.create_agent_token(org2.id, "Org2 Agent")
{:ok, cloud, _} = Agents.create_cloud_poller("Cloud Poller")
tokens = Agents.list_all_agent_tokens()
assert length(tokens) == 3
ids = Enum.map(tokens, & &1.id)
assert token1.id in ids
assert token2.id in ids
assert cloud.id in ids
end
test "preloads organization", %{organization: org} do
{:ok, _token, _} = Agents.create_agent_token(org.id, "Test Agent")
[token] = Agents.list_all_agent_tokens()
assert Ecto.assoc_loaded?(token.organization)
assert token.organization.name == "Test Org"
end
test "orders by newest first", %{organization: org} do
{:ok, token1, _} = Agents.create_agent_token(org.id, "Agent 1")
# Manually set an earlier timestamp so ordering is deterministic
Towerops.Repo.update_all(
from(t in AgentToken, where: t.id == ^token1.id),
set: [inserted_at: ~U[2025-01-01 00:00:00Z]]
)
{:ok, token2, _} = Agents.create_agent_token(org.id, "Agent 2")
tokens = Agents.list_all_agent_tokens()
ids = Enum.map(tokens, & &1.id)
assert List.first(ids) == token2.id
assert List.last(ids) == token1.id
end
test "returns empty list when no tokens exist" do
assert Agents.list_all_agent_tokens() == []
end
end
describe "cloud poller validation constraints" do
test "cloud poller cannot have organization_id" do
# Attempt to create cloud poller with organization_id should fail
{_token, changeset} = AgentToken.build_token("fake-org-id", "Invalid", is_cloud_poller: true)
refute changeset.valid?
assert "must be nil for cloud pollers" in errors_on(changeset).organization_id
end
test "regular agent must have organization_id" do
# Attempt to create regular agent without organization_id should fail
{_token, changeset} = AgentToken.build_token(nil, "Invalid", is_cloud_poller: false)
refute changeset.valid?
assert "can't be blank for non-cloud pollers" in errors_on(changeset).organization_id
end
end
describe "cloud poller polling targets" do
setup do
user = user_fixture()
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id, bypass_limits: true)
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id, bypass_limits: true)
{:ok, site1} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: org1.id})
{:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org2.id})
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Cloud Poller")
%{
org1: org1,
org2: org2,
site1: site1,
site2: site2,
cloud_poller: cloud_poller
}
end
test "cloud poller can be assigned to devices across organizations", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2,
cloud_poller: cloud_poller
} do
# Create devices in different organizations
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.2.1",
site_id: site2.id,
organization_id: org2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Assign both devices to cloud poller
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device2.id)
# Cloud poller should see both devices across organizations
targets = Agents.list_agent_polling_targets(cloud_poller.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
end
test "cloud poller can be organization default for multiple orgs", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2,
cloud_poller: cloud_poller
} do
# Set cloud poller as default for both organizations
{:ok, _} =
Towerops.Organizations.update_organization(org1, %{
default_agent_token_id: cloud_poller.id
})
{:ok, _} =
Towerops.Organizations.update_organization(org2, %{
default_agent_token_id: cloud_poller.id
})
# Create devices in both organizations
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.2.1",
site_id: site2.id,
organization_id: org2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Cloud poller should see both devices via organization inheritance
targets = Agents.list_agent_polling_targets(cloud_poller.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
end
test "cloud poller can be site default for sites in different orgs", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2,
cloud_poller: cloud_poller
} do
# Set cloud poller as default for both sites
{:ok, _} = Towerops.Sites.update_site(site1, %{agent_token_id: cloud_poller.id})
{:ok, _} = Towerops.Sites.update_site(site2, %{agent_token_id: cloud_poller.id})
# Create devices in both sites
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.2.1",
site_id: site2.id,
organization_id: org2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Cloud poller should see both devices via site inheritance
targets = Agents.list_agent_polling_targets(cloud_poller.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
end
end
describe "toggle_agent_debug/3" do
test "enables remote debug and creates audit log", %{organization: org, user: user} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
# Make user a superuser
user = Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
# Enable debug
assert {:ok, updated_agent} = Agents.toggle_agent_debug(agent_token, true, user)
assert updated_agent.allow_remote_debug == true
# Verify audit log was created
audit_log = Repo.one(AuditLog)
assert audit_log.superuser_id == user.id
assert audit_log.action == "agent_debug_enable"
assert audit_log.metadata["agent_token_id"] == agent_token.id
assert audit_log.metadata["agent_name"] == "Test Agent"
end
test "disables remote debug and creates audit log", %{organization: org, user: user} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
# Make user a superuser
user = Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
# First enable it
{:ok, agent_token} = Agents.toggle_agent_debug(agent_token, true, user)
# Now disable it
assert {:ok, updated_agent} = Agents.toggle_agent_debug(agent_token, false, user)
assert updated_agent.allow_remote_debug == false
# Verify audit log was created with correct action
audit_logs = Repo.all(AuditLog)
disable_log = Enum.find(audit_logs, &(&1.action == "agent_debug_disable"))
assert disable_log.superuser_id == user.id
assert disable_log.metadata["agent_token_id"] == agent_token.id
end
end
describe "broadcast_assignment_change/2" do
test "broadcasts assignment change with valid agent_token_id" do
# Subscribe to the PubSub topic
agent_token_id = Ecto.UUID.generate()
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token_id}:assignments")
# Broadcast the change
assert :ok = Agents.broadcast_assignment_change(agent_token_id, :device_assigned)
# Verify we received the broadcast
assert_receive {:assignments_changed, :device_assigned}
end
test "handles nil agent_token_id gracefully" do
# Should return :ok without broadcasting
assert :ok = Agents.broadcast_assignment_change(nil, :device_unassigned)
# No assertion needed - just verify it doesn't crash
end
end
describe "broadcast_agent_change/2" do
test "broadcasts agent_created on admin:agents topic", %{organization: org} do
Phoenix.PubSub.subscribe(Towerops.PubSub, "admin:agents")
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Broadcast Test Agent")
assert_receive {:agent_created, agent_token_id, false}
assert agent_token_id == agent_token.id
end
test "broadcasts agent_created for cloud pollers", %{} do
Phoenix.PubSub.subscribe(Towerops.PubSub, "admin:agents")
{:ok, cloud_poller, _token} = Agents.create_cloud_poller("Broadcast Cloud Poller")
assert_receive {:agent_created, agent_token_id, true}
assert agent_token_id == cloud_poller.id
end
test "broadcasts agent_deleted when agent is removed", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Delete Broadcast Agent")
Phoenix.PubSub.subscribe(Towerops.PubSub, "admin:agents")
{:ok, _} = Agents.delete_agent_token(agent_token.id)
assert_receive {:agent_deleted, agent_token_id, false}
assert agent_token_id == agent_token.id
end
end
describe "should_phoenix_poll_device?/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, local_agent, _} = Agents.create_agent_token(org.id, "Local Agent")
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Cloud Poller")
%{
site: site,
device: device,
local_agent: local_agent,
cloud_poller: cloud_poller
}
end
test "returns true when device has no agent assigned", %{device: device} do
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == true
end
test "returns true when device is assigned to cloud poller via direct assignment", %{
device: device,
cloud_poller: cloud_poller
} do
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device.id)
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == true
end
test "returns true when device inherits cloud poller from site", %{
device: device,
site: site,
cloud_poller: cloud_poller
} do
{:ok, _} = Towerops.Sites.update_site(site, %{agent_token_id: cloud_poller.id})
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == true
end
test "returns true when device inherits cloud poller from organization", %{
organization: org,
device: device,
cloud_poller: cloud_poller
} do
{:ok, _} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: cloud_poller.id})
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == true
end
test "returns true when device inherits global cloud poller", %{device: device, cloud_poller: cloud_poller} do
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == true
# Clean up
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
end
test "returns false when device is assigned to local agent via direct assignment", %{
device: device,
local_agent: local_agent
} do
{:ok, _} = Agents.assign_device_to_agent(local_agent.id, device.id)
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == false
end
test "returns false when device inherits local agent from site", %{
device: device,
site: site,
local_agent: local_agent
} do
{:ok, _} = Towerops.Sites.update_site(site, %{agent_token_id: local_agent.id})
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == false
end
test "returns false when device inherits local agent from organization", %{
organization: org,
device: device,
local_agent: local_agent
} do
{:ok, _} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: local_agent.id})
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
assert Agents.should_phoenix_poll_device?(device) == false
end
test "device-level assignment takes precedence over site/org (local overrides cloud)", %{
organization: org,
site: site,
device: device,
local_agent: local_agent,
cloud_poller: cloud_poller
} do
# Set cloud poller at org and site level
{:ok, _} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: cloud_poller.id})
{:ok, _} = Towerops.Sites.update_site(site, %{agent_token_id: cloud_poller.id})
# But assign local agent at device level
{:ok, _} = Agents.assign_device_to_agent(local_agent.id, device.id)
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
# Should return false because device is assigned to local agent
assert Agents.should_phoenix_poll_device?(device) == false
end
test "device-level assignment takes precedence over site/org (cloud overrides local)", %{
organization: org,
site: site,
device: device,
local_agent: local_agent,
cloud_poller: cloud_poller
} do
# Set local agent at org and site level
{:ok, _} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: local_agent.id})
{:ok, _} = Towerops.Sites.update_site(site, %{agent_token_id: local_agent.id})
# But assign cloud poller at device level
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device.id)
device = device |> Repo.reload!() |> Repo.preload(site: [organization: :default_agent_token])
# Should return true because device is assigned to cloud poller
assert Agents.should_phoenix_poll_device?(device) == true
end
end
describe "device_has_effective_agent?/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: org.id
})
{:ok, local_agent, _} = Agents.create_agent_token(org.id, "Local Agent")
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Cloud Poller")
%{
site: site,
device: device,
local_agent: local_agent,
cloud_poller: cloud_poller
}
end
test "returns false when device has no agent assigned", %{device: device} do
assert Agents.device_has_effective_agent?(device.id) == false
end
test "returns true when device is assigned to local agent", %{
device: device,
local_agent: local_agent
} do
{:ok, _} = Agents.assign_device_to_agent(local_agent.id, device.id)
assert Agents.device_has_effective_agent?(device.id) == true
end
test "returns true when device is assigned to cloud poller", %{
device: device,
cloud_poller: cloud_poller
} do
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device.id)
assert Agents.device_has_effective_agent?(device.id) == true
end
test "returns true when site has agent assigned", %{
device: device,
site: site,
local_agent: local_agent
} do
{:ok, _} = Towerops.Sites.update_site(site, %{agent_token_id: local_agent.id})
assert Agents.device_has_effective_agent?(device.id) == true
end
test "returns true when organization has default agent", %{
organization: org,
device: device,
local_agent: local_agent
} do
{:ok, _} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: local_agent.id})
assert Agents.device_has_effective_agent?(device.id) == true
end
test "returns true when global default cloud poller is set", %{
device: device,
cloud_poller: cloud_poller
} do
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
# Refresh the cache so it picks up the new global default
AgentCache.refresh_global_default()
AgentCache.invalidate(device.id)
assert Agents.device_has_effective_agent?(device.id) == true
# Clean up
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
AgentCache.refresh_global_default()
end
end
describe "list_updatable_agents/0" do
test "returns agents with recent last_seen_at", %{organization: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Active Agent")
# Set last_seen_at to now
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{"version" => "0.1.0"})
agents = Agents.list_updatable_agents()
assert Enum.any?(agents, &(&1.id == agent.id))
end
test "excludes agents not seen in over 10 minutes", %{organization: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Stale Agent")
# Set last_seen_at to 15 minutes ago
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
Repo.update_all(
from(t in AgentToken, where: t.id == ^agent.id),
set: [last_seen_at: stale_time]
)
agents = Agents.list_updatable_agents()
refute Enum.any?(agents, &(&1.id == agent.id))
end
test "excludes disabled agents", %{organization: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Disabled Agent")
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{"version" => "0.1.0"})
{:ok, _} = Agents.revoke_agent_token(agent.id)
agents = Agents.list_updatable_agents()
refute Enum.any?(agents, &(&1.id == agent.id))
end
test "includes cloud pollers", %{} do
{:ok, cloud_poller, _token} = Agents.create_cloud_poller("Cloud Poller")
Agents.update_agent_token_heartbeat(cloud_poller.id, "10.0.0.1", %{"version" => "0.1.0"})
agents = Agents.list_updatable_agents()
assert Enum.any?(agents, &(&1.id == cloud_poller.id))
end
test "excludes agents with nil last_seen_at", %{organization: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Never Seen Agent")
agents = Agents.list_updatable_agents()
refute Enum.any?(agents, &(&1.id == agent.id))
end
end
describe "restart_agent/1" do
test "broadcasts restart_requested on lifecycle topic", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
assert :ok = Agents.restart_agent(agent_token.id)
assert_receive :restart_requested
end
end
describe "update_agent/3" do
test "broadcasts update_requested with url and checksum on lifecycle topic", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
url = "https://github.com/towerops-app/towerops-agent/releases/download/v0.2.0/towerops-agent-linux-amd64"
checksum = "abc123def456"
assert :ok = Agents.update_agent(agent_token.id, url, checksum)
assert_receive {:update_requested, ^url, ^checksum}
end
end
describe "broadcast_mass_update/0" do
test "returns error when GitHub API is unavailable" do
# In test environment, GitHub returns 404 for the private repo
assert {:error, _reason} = Agents.broadcast_mass_update()
end
end
end