towerops/test/towerops/devices_test.exs
Graham McIntire 1318b1b6b7
feat: live device timestamp updates and org-scoped alert PubSub
Always broadcast from update_device_status/2 so the device index
LiveView refreshes even when status hasn't changed. Add a 15-second
tick timer to keep "Last Checked" timestamps fresh between polls.
Scope alert PubSub topics to organization for defense-in-depth.
2026-02-12 11:01:56 -06:00

2357 lines
73 KiB
Elixir

defmodule Towerops.EquipmentTest do
use Towerops.DataCase
use ExUnitProperties
alias Towerops.Devices
alias Towerops.Devices.Device
describe "device" do
import Towerops.AccountsFixtures
alias Device, as: DeviceSchema
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
@valid_attrs %{
name: "Router 1",
ip_address: "192.168.1.1",
description: "Main router",
monitoring_enabled: true,
check_interval_seconds: 300
}
@update_attrs %{
name: "Updated Router",
ip_address: "192.168.1.2",
description: "Updated",
monitoring_enabled: false
}
@invalid_attrs %{name: nil, ip_address: nil}
test "list_site_devices/1 returns all devices for a site", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert Devices.list_site_devices(site.id) == [device]
end
test "list_organization_devices/1 returns all devices for an organization", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
result = Devices.list_organization_devices(organization.id)
assert length(result) == 1
assert hd(result).id == device.id
end
test "list_monitored_devices/0 returns only device with monitoring enabled", %{
organization: organization,
site: site
} do
{:ok, monitored} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
{:ok, _not_monitored} =
Devices.create_device(%{
name: "Not Monitored",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false
})
result = Devices.list_monitored_devices()
assert length(result) == 1
assert hd(result).id == monitored.id
end
test "get_device!/1 returns the device with given id", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert Devices.get_device!(device.id).id == device.id
end
test "get_site_equipment!/2 returns device for specific site", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert Devices.get_site_device!(site.id, device.id).id == device.id
end
test "create_device/1 with valid data creates device", %{organization: organization, site: site} do
attrs = Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id})
assert {:ok, %DeviceSchema{} = device} = Devices.create_device(attrs)
assert device.name == "Router 1"
assert device.ip_address == "192.168.1.1"
assert device.status == :unknown
assert device.monitoring_enabled == true
assert device.check_interval_seconds == 300
end
test "create_device/1 with valid IPv6 address", %{organization: organization, site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "2001:0db8:85a3::8a2e:0370:7334")
attrs = Map.merge(attrs, %{site_id: site.id, organization_id: organization.id})
assert {:ok, %DeviceSchema{}} = Devices.create_device(attrs)
end
test "create_device/1 with invalid IP address returns error", %{organization: organization, site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "invalid-ip")
attrs = Map.merge(attrs, %{site_id: site.id, organization_id: organization.id})
assert {:error, changeset} = Devices.create_device(attrs)
assert "must be a valid IPv4 or IPv6 address" in errors_on(changeset).ip_address
end
test "create_device/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Devices.create_device(@invalid_attrs)
end
test "update_device/2 with valid data updates the device", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert {:ok, %DeviceSchema{} = device} =
Devices.update_device(device, @update_attrs)
assert device.name == "Updated Router"
assert device.ip_address == "192.168.1.2"
assert device.monitoring_enabled == false
end
test "update_device/2 with invalid data returns error changeset", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert {:error, %Ecto.Changeset{}} =
Devices.update_device(device, @invalid_attrs)
assert Devices.get_device!(device.id).name == device.name
end
test "change_device/1 returns an device changeset", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert %Ecto.Changeset{} = Devices.change_device(device)
end
test "update_device_status/2 updates status and timestamps", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert {:ok, updated} = Devices.update_device_status(device, :up)
assert updated.status == :up
assert updated.last_checked_at
assert updated.last_status_change_at
end
test "update_device_status/2 only updates last_checked_at if status unchanged", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
{:ok, device} = Devices.update_device_status(device, :up)
# Manually set timestamps to 1 hour ago to avoid sleeping
# Must truncate to :second to match DB column type
past_time =
DateTime.utc_now()
|> DateTime.add(-3600, :second)
|> DateTime.truncate(:second)
{:ok, device} =
device
|> Ecto.Changeset.change(%{last_checked_at: past_time, last_status_change_at: past_time})
|> Towerops.Repo.update()
first_change_at = device.last_status_change_at
first_checked_at = device.last_checked_at
{:ok, updated} = Devices.update_device_status(device, :up)
# Status change timestamp should not change
assert updated.last_status_change_at == first_change_at
# But checked_at should be updated (to now, which is after 1 hour ago)
assert DateTime.after?(updated.last_checked_at, first_checked_at)
end
test "list_snmp_enabled_devices/0 returns only device with SNMP enabled", %{organization: organization, site: site} do
{:ok, snmp_enabled} =
Devices.create_device(%{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: "public"
})
{:ok, _snmp_disabled} =
Devices.create_device(%{
name: "Non-SNMP Router",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false
})
result = Devices.list_snmp_enabled_devices()
assert length(result) == 1
assert hd(result).id == snmp_enabled.id
end
test "update_snmp_poll_time/1 updates last_snmp_poll_at timestamp", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert device.last_snmp_poll_at == nil
assert {:ok, updated} = Devices.update_snmp_poll_time(device)
assert updated.last_snmp_poll_at
assert DateTime.before?(updated.last_snmp_poll_at, DateTime.utc_now())
end
test "create_device/1 with SNMP enabled and nil community string succeeds", %{organization: organization, site: site} do
attrs = %{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: nil
}
assert {:ok, %DeviceSchema{} = device} = Devices.create_device(attrs)
assert device.snmp_enabled == true
assert device.snmp_community == nil
end
test "create_device/1 with SNMP enabled and empty community string succeeds", %{
organization: organization,
site: site
} do
attrs = %{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: ""
}
# Empty string should be converted to nil by the database
assert {:ok, %DeviceSchema{} = device} = Devices.create_device(attrs)
assert device.snmp_enabled == true
# Empty strings are stored as nil in the database
assert device.snmp_community == nil || device.snmp_community == ""
end
test "reorder_device/2 reorders device to first position", %{organization: organization, site: site} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
{:ok, device3} =
Devices.create_device(%{
name: "Device 3",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: organization.id
})
# Move device3 to first position
{:ok, _updated} = Devices.reorder_device(device3.id, 1)
# Verify order
devices = Devices.list_site_devices(site.id)
assert Enum.at(devices, 0).id == device3.id
assert Enum.at(devices, 0).display_order == 1
assert Enum.at(devices, 1).id == device1.id
assert Enum.at(devices, 1).display_order == 2
assert Enum.at(devices, 2).id == device2.id
assert Enum.at(devices, 2).display_order == 3
end
test "reorder_device/2 reorders device to last position", %{organization: organization, site: site} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
{:ok, device3} =
Devices.create_device(%{
name: "Device 3",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: organization.id
})
# Move device1 to last position
{:ok, _updated} = Devices.reorder_device(device1.id, 3)
# Verify order
devices = Devices.list_site_devices(site.id)
assert Enum.at(devices, 0).id == device2.id
assert Enum.at(devices, 0).display_order == 1
assert Enum.at(devices, 1).id == device3.id
assert Enum.at(devices, 1).display_order == 2
assert Enum.at(devices, 2).id == device1.id
assert Enum.at(devices, 2).display_order == 3
end
test "reorder_device/2 reorders device to middle position", %{organization: organization, site: site} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
{:ok, device3} =
Devices.create_device(%{
name: "Device 3",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: organization.id
})
# Move device3 to middle position
{:ok, _updated} = Devices.reorder_device(device3.id, 2)
# Verify order
devices = Devices.list_site_devices(site.id)
assert Enum.at(devices, 0).id == device1.id
assert Enum.at(devices, 0).display_order == 1
assert Enum.at(devices, 1).id == device3.id
assert Enum.at(devices, 1).display_order == 2
assert Enum.at(devices, 2).id == device2.id
assert Enum.at(devices, 2).display_order == 3
end
test "reorder_device/2 maintains continuous numbering", %{organization: organization, site: site} do
{:ok, _device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device3} =
Devices.create_device(%{
name: "Device 3",
ip_address: "192.168.1.3",
site_id: site.id,
organization_id: organization.id
})
{:ok, device4} =
Devices.create_device(%{
name: "Device 4",
ip_address: "192.168.1.4",
site_id: site.id,
organization_id: organization.id
})
# Reorder multiple times
{:ok, _} = Devices.reorder_device(device2.id, 1)
{:ok, _} = Devices.reorder_device(device4.id, 2)
# Verify all have continuous display_order values
devices = Devices.list_site_devices(site.id)
display_orders = Enum.map(devices, & &1.display_order)
assert display_orders == [1, 2, 3, 4]
end
test "reorder_device/2 only affects devices in same site", %{organization: organization, site: site} do
# Create devices in first site
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
# Create second site and device
{:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: organization.id})
{:ok, device3} =
Devices.create_device(%{
name: "Device 3",
ip_address: "192.168.2.1",
site_id: site2.id,
organization_id: organization.id
})
# Reorder device in first site
{:ok, _} = Devices.reorder_device(device1.id, 2)
# Verify first site is reordered
site1_devices = Devices.list_site_devices(site.id)
assert Enum.at(site1_devices, 0).id == device2.id
assert Enum.at(site1_devices, 1).id == device1.id
# Verify second site is unchanged (no display_order set)
site2_devices = Devices.list_site_devices(site2.id)
assert length(site2_devices) == 1
assert hd(site2_devices).id == device3.id
assert is_nil(hd(site2_devices).display_order)
end
test "reset_organization_device_order/1 clears all display_order values", %{
organization: organization,
site: site
} do
{:ok, device1} =
Devices.create_device(%{
name: "Zebra Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Alpha Device",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
# Set custom order
{:ok, _} = Devices.reorder_device(device1.id, 1)
{:ok, _} = Devices.reorder_device(device2.id, 2)
# Verify custom order is set
devices_before = Devices.list_site_devices(site.id)
assert List.first(devices_before).id == device1.id
assert List.first(devices_before).display_order == 1
# Reset order
{count, _} = Devices.reset_organization_device_order(organization.id)
assert count == 2
# Verify display_order is cleared and falls back to alphabetical
devices_after = Devices.list_site_devices(site.id)
assert List.first(devices_after).name == "Alpha Device"
assert List.first(devices_after).display_order == nil
assert List.last(devices_after).name == "Zebra Device"
assert List.last(devices_after).display_order == nil
end
end
describe "SNMP configuration inheritance" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "get_snmp_config/1 returns device-level community string when set", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: "device-community"
})
config = Devices.get_snmp_config(device)
assert config.community == "device-community"
assert config.source == :device
end
test "get_snmp_config/1 returns site-level community when device has nil", %{
organization: organization,
site: site
} do
# Update site with SNMP community
{:ok, site} =
Towerops.Sites.update_site(site, %{
snmp_community: "site-community",
snmp_version: "2c"
})
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: nil
})
device = Towerops.Repo.preload(device, site: :organization)
config = Devices.get_snmp_config(device)
assert config.community == "site-community"
assert config.source == :site
end
test "get_snmp_config/1 returns org-level community when device and site have nil", %{
organization: organization,
site: site
} do
# Update organization with SNMP community
{:ok, _organization} =
Towerops.Organizations.update_organization(organization, %{
snmp_community: "org-community",
snmp_version: "2c"
})
# Reload site with updated organization
site = Towerops.Repo.preload(site, :organization, force: true)
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: nil
})
device = Towerops.Repo.preload(device, site: :organization)
config = Devices.get_snmp_config(device)
assert config.community == "org-community"
assert config.source == :organization
end
test "get_snmp_config/1 returns nil when all levels have nil", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: nil
})
device = Towerops.Repo.preload(device, site: :organization)
config = Devices.get_snmp_config(device)
assert config.community == nil
assert config.source == :organization
end
test "get_snmp_config/1 prioritizes device over site over org", %{
organization: organization,
site: site
} do
# Set community at all levels
{:ok, _organization} =
Towerops.Organizations.update_organization(organization, %{
snmp_community: "org-community",
snmp_version: "2c"
})
{:ok, site} =
Towerops.Sites.update_site(site, %{
snmp_community: "site-community",
snmp_version: "2c"
})
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: "device-community"
})
device = Towerops.Repo.preload(device, site: :organization)
config = Devices.get_snmp_config(device)
# Should use device-level community
assert config.community == "device-community"
assert config.source == :device
end
end
describe "events" do
import Towerops.AccountsFixtures
alias Towerops.Devices.Event
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
%{device: device}
end
test "create_event/1 creates an device event", %{device: device} do
attrs = %{
device_id: device.id,
event_type: "interface_up",
severity: "info",
message: "Interface came online",
metadata: %{"interface" => "eth0"},
occurred_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %Event{} = event} = Devices.create_event(attrs)
assert event.device_id == device.id
assert event.event_type == "interface_up"
assert event.severity == "info"
assert event.message == "Interface came online"
assert event.metadata == %{"interface" => "eth0"}
end
test "create_event/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Devices.create_event(%{})
end
test "list_devices_events/2 returns events for device ordered by most recent", %{
device: device
} do
now = DateTime.utc_now()
{:ok, event1} =
Devices.create_event(%{
device_id: device.id,
event_type: "interface_up",
severity: "info",
message: "Interface came up",
occurred_at: DateTime.add(now, -3600, :second)
})
{:ok, event2} =
Devices.create_event(%{
device_id: device.id,
event_type: "interface_down",
severity: "warning",
message: "Interface went down",
occurred_at: DateTime.add(now, -1800, :second)
})
events = Devices.list_devices_events(device.id)
assert length(events) == 2
# Should be ordered by most recent first
assert hd(events).id == event2.id
assert List.last(events).id == event1.id
end
test "list_devices_events/2 limits results", %{device: device} do
now = DateTime.utc_now()
# Create 10 events
for i <- 1..10 do
Devices.create_event(%{
device_id: device.id,
event_type: "interface_down",
severity: "warning",
message: "Interface down event #{i}",
occurred_at: DateTime.add(now, -i * 60, :second)
})
end
events = Devices.list_devices_events(device.id, 5)
assert length(events) == 5
end
end
describe "additional coverage tests" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "get_device/1 returns device when exists", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
found = Devices.get_device(device.id)
assert found
assert found.id == device.id
end
test "get_device/1 returns nil when not exists" do
assert Devices.get_device(Ecto.UUID.generate()) == nil
end
test "get_device_with_details/1 preloads associations", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
result = Devices.get_device_with_details(device.id)
assert result.id == device.id
assert Ecto.assoc_loaded?(result.site)
end
@tag :skip
test "delete_device/1 deletes the device", %{organization: organization, site: site} do
# Skipped: Requires Monitoring.Supervisor to be running
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
assert {:ok, _deleted} = Devices.delete_device(device)
assert Devices.get_device(device.id) == nil
end
test "count_organization_devices/1 returns correct count", %{organization: organization, site: site} do
{:ok, _device1} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
assert Devices.count_organization_devices(organization.id) == 2
end
test "count_site_devices/1 returns correct count", %{organization: organization, site: site} do
{:ok, _device1} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
assert Devices.count_site_devices(site.id) == 2
end
test "count_site_devices_down/1 returns correct count", %{organization: organization, site: site} do
{:ok, device1} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
# Mark one as down
{:ok, _} = Devices.update_device_status(device1, :down)
assert Devices.count_site_devices_down(site.id) == 1
end
test "list_organization_devices/2 filters by site_id", %{organization: organization, site: site} do
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: organization.id
})
{:ok, device1} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site2.id,
organization_id: organization.id
})
result = Devices.list_organization_devices(organization.id, %{"site_id" => site.id})
assert length(result) == 1
assert hd(result).id == device1.id
end
test "list_organization_devices/2 filters by status", %{organization: organization, site: site} do
{:ok, device1} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
# Mark one as up
Devices.update_device_status(device1, :up)
result = Devices.list_organization_devices(organization.id, %{"status" => "up"})
assert length(result) == 1
assert hd(result).id == device1.id
end
test "get_snmp_config/1 with device ID string", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: "test-community"
})
config = Devices.get_snmp_config(device.id)
assert config.community == "test-community"
assert config.source == :device
end
end
describe "automatic rediscovery on device updates" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "triggers discovery when snmp_enabled changes from false to true", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update to enable SNMP
{:ok, _updated} = Devices.update_device(device, %{snmp_enabled: true})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "triggers discovery when snmp_version changes while enabled", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c"
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update SNMP version
{:ok, _updated} = Devices.update_device(device, %{snmp_version: "1"})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "triggers discovery when snmp_port changes while enabled", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_port: 161
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update SNMP port
{:ok, _updated} = Devices.update_device(device, %{snmp_port: 1161})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "triggers discovery when monitoring_enabled changes to true with SNMP enabled", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
monitoring_enabled: false
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Enable monitoring
{:ok, _updated} = Devices.update_device(device, %{monitoring_enabled: true})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "does not trigger discovery when monitoring_enabled changes but SNMP disabled", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false,
monitoring_enabled: false
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Enable monitoring (but SNMP is disabled)
{:ok, _updated} = Devices.update_device(device, %{monitoring_enabled: true})
# Check that NO discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
test "does not trigger discovery when SNMP is disabled", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_version: "2c"
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Disable SNMP
{:ok, _updated} = Devices.update_device(device, %{snmp_enabled: false})
# Check that NO discovery job was enqueued (SNMP is disabled)
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
test "does not trigger discovery for unrelated field changes", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update unrelated fields (name, description)
{:ok, _updated} = Devices.update_device(device, %{name: "Updated Router"})
# Check that NO discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
test "does not trigger discovery when snmp_version changes while disabled", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false,
snmp_version: "2c"
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update SNMP version (but SNMP is disabled)
{:ok, _updated} = Devices.update_device(device, %{snmp_version: "1"})
# Check that NO discovery job was enqueued (SNMP is disabled)
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
end
describe "property-based tests" do
import Towerops.AccountsFixtures
property "creating devices with valid IPs always succeeds" do
# Create fixtures once outside the property check
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
check all(
name <- string(:alphanumeric, min_length: 2, max_length: 20),
octet1 <- integer(0..255),
octet2 <- integer(0..255),
octet3 <- integer(0..255),
octet4 <- integer(1..254),
max_runs: 20
) do
ip_address = "#{octet1}.#{octet2}.#{octet3}.#{octet4}"
attrs = %{
name: name,
ip_address: ip_address,
site_id: site.id,
organization_id: organization.id
}
# Use bypass_limits since property tests may create > 10 devices
assert {:ok, %Device{} = device} = Devices.create_device(attrs, bypass_limits: true)
assert device.ip_address == ip_address
assert device.status == :unknown
end
end
property "device status changes are idempotent" do
check all(status <- member_of([:up, :down, :unknown]), max_runs: 10) do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.100",
site_id: site.id,
organization_id: organization.id
})
# Update status twice
{:ok, updated1} = Devices.update_device_status(device, status)
{:ok, updated2} = Devices.update_device_status(updated1, status)
assert updated2.status == status
# Second update should not change last_status_change_at
assert updated2.last_status_change_at == updated1.last_status_change_at
end
end
property "device counters are always non-negative" do
check all(device_count <- integer(0..5), max_runs: 10) do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
# Create specified number of devices
if device_count > 0 do
for i <- 1..device_count do
Devices.create_device(%{
name: "Device #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
organization_id: organization.id
})
end
end
org_count = Devices.count_organization_devices(organization.id)
site_count = Devices.count_site_devices(site.id)
down_count = Devices.count_site_devices_down(site.id)
assert org_count >= 0
assert site_count >= 0
assert down_count >= 0
assert down_count <= site_count
assert org_count == site_count
end
end
property "SNMP config prioritization is consistent" do
check all(
device_community <- one_of([constant(nil), string(:alphanumeric, min_length: 1, max_length: 20)]),
site_community <- one_of([constant(nil), string(:alphanumeric, min_length: 1, max_length: 20)]),
max_runs: 10
) do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
# Update site with community if provided
site =
if site_community do
{:ok, updated_site} = Towerops.Sites.update_site(site, %{snmp_community: site_community})
updated_site
else
site
end
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true,
snmp_community: device_community
})
device = Towerops.Repo.preload(device, site: :organization)
config = Devices.get_snmp_config(device)
# Device-level always takes precedence if set
if device_community do
assert config.community == device_community
assert config.source == :device
else
if site_community do
assert config.community == site_community
assert config.source == :site
else
assert config.source in [:organization, :default]
end
end
end
end
end
describe "device creation with subscription limits" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "blocks device creation when at 10-device limit", %{organization: organization, site: site} do
# Create 10 devices (the limit)
for i <- 1..10 do
{:ok, _device} =
Devices.create_device(%{
name: "Device #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
end
# 11th device should be blocked
assert {:error, changeset} =
Devices.create_device(%{
name: "Device 11",
ip_address: "192.168.1.11",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
assert %{base: ["You've reached your plan limit of 10 devices. Upgrade to add more."]} =
errors_on(changeset)
end
test "allows device creation when under limit", %{organization: organization, site: site} do
# Create 9 devices
for i <- 1..9 do
{:ok, _device} =
Devices.create_device(%{
name: "Device #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
end
# 10th device should succeed
assert {:ok, _device} =
Devices.create_device(%{
name: "Device 10",
ip_address: "192.168.1.10",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
end
test "bypasses limit with bypass_limits option", %{organization: organization, site: site} do
# Create 10 devices (at limit)
for i <- 1..10 do
{:ok, _device} =
Devices.create_device(%{
name: "Device #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
end
# 11th device succeeds with bypass
assert {:ok, _device} =
Devices.create_device(
%{
name: "Device 11",
ip_address: "192.168.1.11",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
},
bypass_limits: true
)
end
test "counts devices across multiple sites in same organization", %{
organization: organization,
site: site1
} do
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: organization.id
})
# Create 6 devices in site1
for i <- 1..6 do
{:ok, _device} =
Devices.create_device(%{
name: "Site1 Device #{i}",
ip_address: "192.168.1.#{i}",
site_id: site1.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
end
# Create 4 devices in site2 (total = 10)
for i <- 1..4 do
{:ok, _device} =
Devices.create_device(%{
name: "Site2 Device #{i}",
ip_address: "192.168.2.#{i}",
site_id: site2.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
end
# 11th device in either site should be blocked
assert {:error, changeset} =
Devices.create_device(%{
name: "Site1 Device 7",
ip_address: "192.168.1.7",
site_id: site1.id,
organization_id: organization.id,
monitoring_enabled: false,
snmp_enabled: false
})
assert %{base: ["You've reached your plan limit of 10 devices. Upgrade to add more."]} =
errors_on(changeset)
end
end
describe "GDPR data access - list_devices_for_organizations/1" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id)
# Create org2 with a different owner to avoid subscription limits
other_user = user_fixture()
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, other_user.id)
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Org1 Site",
organization_id: org1.id
})
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Org2 Site",
organization_id: org2.id
})
%{org1: org1, org2: org2, site1: site1, site2: site2}
end
test "returns all devices for given organizations", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2
} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site2.id,
organization_id: org2.id
})
result = Devices.list_devices_for_organizations([org1.id, org2.id])
assert length(result) == 2
device_ids = Enum.map(result, & &1.id)
assert device1.id in device_ids
assert device2.id in device_ids
end
test "returns only devices from specified organizations", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2
} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site2.id,
organization_id: org2.id
})
# Only request devices from org1
result = Devices.list_devices_for_organizations([org1.id])
assert length(result) == 1
assert hd(result).id == device1.id
end
test "returns empty list when organizations have no devices", %{org1: org1} do
result = Devices.list_devices_for_organizations([org1.id])
assert result == []
end
test "returns empty list for empty organization list" do
result = Devices.list_devices_for_organizations([])
assert result == []
end
test "preloads site association", %{org1: org1, site1: site1} do
{:ok, _device} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id
})
result = Devices.list_devices_for_organizations([org1.id])
assert length(result) == 1
loaded_device = hd(result)
assert Ecto.assoc_loaded?(loaded_device.site)
assert loaded_device.site.id == site1.id
end
test "orders devices by organization and name", %{org1: org1, org2: org2, site1: site1, site2: site2} do
{:ok, _zebra_dev} =
Devices.create_device(%{
name: "Zebra Device",
ip_address: "192.168.1.1",
site_id: site1.id,
organization_id: org1.id
})
{:ok, _alpha_dev} =
Devices.create_device(%{
name: "Alpha Device",
ip_address: "192.168.1.2",
site_id: site1.id,
organization_id: org1.id
})
{:ok, _beta_dev} =
Devices.create_device(%{
name: "Beta Device",
ip_address: "192.168.2.1",
site_id: site2.id,
organization_id: org2.id
})
result = Devices.list_devices_for_organizations([org1.id, org2.id])
assert length(result) == 3
# Devices should be ordered by org ID, then by name alphabetically
# Group by org to verify ordering within each org
org1_devices = Enum.filter(result, &(&1.site.organization_id == org1.id))
org2_devices = Enum.filter(result, &(&1.site.organization_id == org2.id))
# Within org1, should be alphabetical by name
assert length(org1_devices) == 2
org1_names = Enum.map(org1_devices, & &1.name)
assert org1_names == ["Alpha Device", "Zebra Device"]
# Org2 has one device
assert length(org2_devices) == 1
assert hd(org2_devices).name == "Beta Device"
# Overall list should be ordered by org_id first
org_ids = Enum.map(result, & &1.site.organization_id)
assert org_ids == Enum.sort(org_ids)
end
end
describe "PubSub broadcasts" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "create_device/1 broadcasts :device_created", %{organization: organization, site: site} do
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
assert_receive {:device_created, org_id}
assert org_id == organization.id
end
test "update_device/2 broadcasts :device_updated", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
{:ok, _updated} = Devices.update_device(device, %{name: "Updated Router"})
assert_receive {:device_updated, org_id}
assert org_id == organization.id
end
test "delete_device/1 broadcasts :device_deleted", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
{:ok, _deleted} = Devices.delete_device(device)
assert_receive {:device_deleted, org_id}
assert org_id == organization.id
end
test "update_device_status/2 broadcasts :device_status_changed on actual status change", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
# Status changes from :unknown to :up — should broadcast
{:ok, _updated} = Devices.update_device_status(device, :up)
assert_receive {:device_status_changed, org_id}
assert org_id == organization.id
end
test "update_device_status/2 broadcasts :device_updated when status unchanged", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# Set initial status to :up
{:ok, device} = Devices.update_device_status(device, :up)
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
# Same status — should broadcast :device_updated (not :device_status_changed)
{:ok, _updated} = Devices.update_device_status(device, :up)
refute_receive {:device_status_changed, _}
assert_receive {:device_updated, org_id}
assert org_id == organization.id
end
end
describe "get_device_by_ip/3" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "returns device matching IP and site", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
found = Devices.get_device_by_ip("192.168.1.1", site.id)
assert found.id == device.id
end
test "returns nil when IP not found in site", %{site: site} do
assert Devices.get_device_by_ip("10.0.0.1", site.id) == nil
end
test "returns nil when site_id is nil" do
assert Devices.get_device_by_ip("192.168.1.1", nil) == nil
end
test "excludes device with given exclude_id", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
assert Devices.get_device_by_ip("192.168.1.1", site.id, device.id) == nil
end
end
describe "get_mikrotik_config/1" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "returns mikrotik config from device", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "MikroTik Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
mikrotik_enabled: true,
mikrotik_username: "admin",
mikrotik_port: 8729,
mikrotik_use_ssl: true
})
config = Devices.get_mikrotik_config(device)
assert config.username == "admin"
assert config.port == 8729
assert config.use_ssl == true
assert config.enabled == true
end
test "returns defaults when fields are nil", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
config = Devices.get_mikrotik_config(device)
assert config.port == 8729
assert config.ssh_port == 22
assert config.use_ssl == true
assert config.enabled == false
end
test "accepts device_id string", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
mikrotik_enabled: true,
mikrotik_username: "admin"
})
config = Devices.get_mikrotik_config(device.id)
assert config.username == "admin"
end
end
describe "get_snmpv3_config/1" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "returns nil for non-v3 device", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_version: "2c"
})
assert Devices.get_snmpv3_config(device) == nil
end
test "returns v3 config for v3 device", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_version: "3",
snmpv3_security_level: "authPriv",
snmpv3_username: "snmpuser",
snmpv3_auth_protocol: "SHA-256",
snmpv3_auth_password: "authpass123",
snmpv3_priv_protocol: "AES",
snmpv3_priv_password: "privpass123"
})
config = Devices.get_snmpv3_config(device)
assert config.security_level == "authPriv"
assert config.username == "snmpuser"
assert config.auth_protocol == "SHA-256"
assert config.priv_protocol == "AES"
end
test "accepts device_id string", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_version: "3",
snmpv3_username: "snmpuser",
snmpv3_auth_password: "authpass123",
snmpv3_priv_password: "privpass123"
})
config = Devices.get_snmpv3_config(device.id)
assert config.username == "snmpuser"
end
end
describe "propagate_site_community_change/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Set community on site so devices will inherit from it
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id,
snmp_community: "site-community"
})
%{organization: organization, site: site}
end
test "updates devices inheriting from site", %{organization: organization, site: site} do
# Device created without community → inherits from site, source = "site"
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: nil
})
assert device.snmp_community_source == "site"
assert :ok = Devices.propagate_site_community_change(site.id, "new-community")
updated = Devices.get_device!(device.id)
assert updated.snmp_community == "new-community"
end
test "does not update devices with device-level community", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: "device-community"
})
assert device.snmp_community_source == "device"
assert :ok = Devices.propagate_site_community_change(site.id, "new-community")
updated = Devices.get_device!(device.id)
assert updated.snmp_community == "device-community"
end
end
describe "propagate_organization_community_change/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Update org with community so devices can inherit from it
{:ok, organization} =
Towerops.Organizations.update_organization(organization, %{snmp_community: "org-community"})
# Site without community so devices inherit from org
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "updates devices inheriting from organization", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: nil
})
assert device.snmp_community_source == "organization"
assert :ok = Devices.propagate_organization_community_change(organization.id, "new-community")
updated = Devices.get_device!(device.id)
assert updated.snmp_community == "new-community"
end
test "does not update devices with device-level source", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: "device-community"
})
assert device.snmp_community_source == "device"
assert :ok = Devices.propagate_organization_community_change(organization.id, "new-community")
updated = Devices.get_device!(device.id)
assert updated.snmp_community == "device-community"
end
end
describe "propagate_site_mikrotik_change/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Set mikrotik creds on site so devices inherit
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id,
mikrotik_username: "site-user"
})
%{organization: organization, site: site}
end
test "updates devices inheriting mikrotik from site", %{organization: organization, site: site} do
# Device without mikrotik_username → inherits from site
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
assert device.mikrotik_credential_source == "site"
assert :ok =
Devices.propagate_site_mikrotik_change(site.id, %{
mikrotik_username: "new-user"
})
updated = Devices.get_device!(device.id)
assert updated.mikrotik_username == "new-user"
end
end
describe "propagate_organization_mikrotik_change/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Set mikrotik creds on org so devices inherit
{:ok, organization} =
Towerops.Organizations.update_organization(organization, %{mikrotik_username: "org-user"})
# Site without mikrotik creds so devices inherit from org
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "updates devices inheriting mikrotik from organization", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
assert device.mikrotik_credential_source == "organization"
assert :ok =
Devices.propagate_organization_mikrotik_change(organization.id, %{
mikrotik_username: "new-user"
})
updated = Devices.get_device!(device.id)
assert updated.mikrotik_username == "new-user"
end
end
describe "propagate_site_snmpv3_change/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Set snmpv3 creds on site
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id,
snmpv3_username: "site-v3-user",
snmpv3_auth_password: "authpass123",
snmpv3_priv_password: "privpass123"
})
%{organization: organization, site: site}
end
test "updates devices inheriting snmpv3 from site", %{organization: organization, site: site} do
# Create device and manually set snmpv3_credential_source to "site"
# (can't use create_device with v3 + no username due to changeset validation order)
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmpv3_username: "site-v3-user",
snmpv3_auth_password: "authpass123",
snmpv3_priv_password: "privpass123",
snmp_version: "3"
})
# Force source to "site" for propagation test
device
|> Ecto.Changeset.change(%{snmpv3_credential_source: "site"})
|> Towerops.Repo.update!()
assert :ok =
Devices.propagate_site_snmpv3_change(site.id, %{
snmpv3_username: "new-v3-user"
})
updated = Devices.get_device!(device.id)
assert updated.snmpv3_username == "new-v3-user"
end
end
describe "propagate_organization_snmpv3_change/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Set snmpv3 creds on org (with passwords required for v3 validation)
{:ok, organization} =
Towerops.Organizations.update_organization(organization, %{
snmpv3_username: "org-v3-user",
snmpv3_auth_password: "authpass123",
snmpv3_priv_password: "privpass123"
})
# Site without snmpv3 creds
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "updates devices inheriting snmpv3 from organization", %{organization: organization, site: site} do
# Create device with v3 credentials and force source to "organization"
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmpv3_username: "org-v3-user",
snmpv3_auth_password: "authpass123",
snmpv3_priv_password: "privpass123",
snmp_version: "3"
})
# Force source to "organization" for propagation test
device
|> Ecto.Changeset.change(%{snmpv3_credential_source: "organization"})
|> Towerops.Repo.update!()
assert :ok =
Devices.propagate_organization_snmpv3_change(organization.id, %{
snmpv3_username: "new-v3-user"
})
updated = Devices.get_device!(device.id)
assert updated.snmpv3_username == "new-v3-user"
end
end
describe "resolve_snmp_community/1" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "returns device community when source is device", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: "my-community"
})
assert Devices.resolve_snmp_community(device) == "my-community"
end
test "returns site community when device has none", %{organization: organization, site: site} do
{:ok, site} = Towerops.Sites.update_site(site, %{snmp_community: "site-community"})
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: nil,
snmp_community_source: "site"
})
assert Devices.resolve_snmp_community(device) == "site-community"
end
test "returns org community when device and site have none", %{organization: organization, site: site} do
{:ok, _org} =
Towerops.Organizations.update_organization(organization, %{snmp_community: "org-community"})
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_community: nil,
snmp_community_source: "organization"
})
assert Devices.resolve_snmp_community(device) == "org-community"
end
end
describe "list_mikrotik_devices_with_api/0" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
test "returns devices with mikrotik enabled and username", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "MikroTik Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
mikrotik_enabled: true,
mikrotik_username: "admin"
})
result = Devices.list_mikrotik_devices_with_api()
device_ids = Enum.map(result, & &1.id)
assert device.id in device_ids
end
test "excludes devices without mikrotik enabled", %{organization: organization, site: site} do
{:ok, _device} =
Devices.create_device(%{
name: "Regular Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
mikrotik_enabled: false
})
result = Devices.list_mikrotik_devices_with_api()
assert result == []
end
end
end