towerops/test/towerops/devices_test.exs
Graham McIntire f0f5aca491
Allow empty SNMP community string for device inheritance
Implemented hierarchical SNMP community string inheritance following TDD:

Schema changes:
- Updated Device.changeset to remove required validation for snmp_community
- Community string is now optional when SNMP is enabled
- Allows inheritance from site or organization level

Inheritance hierarchy:
- Device-level community (highest priority)
- Site-level community
- Organization-level community
- nil (no community set at any level)

Form updates:
- extract_snmp_config now resolves inherited community for SNMP testing
- validate_test_snmp_input provides clearer error message about inheritance
- SNMP test uses effective community from hierarchy

Tests:
- Added tests for creating devices with nil/empty community string
- Added comprehensive SNMP configuration inheritance tests
- Tests verify hierarchy: device > site > org > default
- All 28 device tests passing

All 808 tests passing (2 pre-existing alert notifier failures unrelated to this change)
No Credo warnings
2026-01-17 15:54:06 -06:00

464 lines
15 KiB
Elixir

defmodule Towerops.EquipmentTest do
use Towerops.DataCase
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", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.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.put(@valid_attrs, :site_id, site.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", %{
site: site
} do
{:ok, monitored} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
{:ok, _not_monitored} =
Devices.create_device(%{
name: "Not Monitored",
ip_address: "192.168.1.3",
site_id: site.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", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert Devices.get_device!(device.id).id == device.id
end
test "get_site_equipment!/2 returns device for specific site", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert Devices.get_site_device!(site.id, device.id).id == device.id
end
test "create_device/1 with valid data creates device", %{site: site} do
attrs = Map.put(@valid_attrs, :site_id, site.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", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "2001:0db8:85a3::8a2e:0370:7334")
attrs = Map.put(attrs, :site_id, site.id)
assert {:ok, %DeviceSchema{}} = Devices.create_device(attrs)
end
test "create_device/1 with invalid IP address returns error", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "invalid-ip")
attrs = Map.put(attrs, :site_id, site.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", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.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", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert {:error, %Ecto.Changeset{}} =
Devices.update_device(device, @invalid_attrs)
assert Devices.get_device!(device.id).name == device.name
end
test "delete_device/1 deletes the device", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %DeviceSchema{}} = Devices.delete_device(device)
assert_raise Ecto.NoResultsError, fn -> Devices.get_device!(device.id) end
end
test "change_device/1 returns an device changeset", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert %Ecto.Changeset{} = Devices.change_device(device)
end
test "update_device_status/2 updates status and timestamps", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.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", %{
site: site
} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
{:ok, device} = Devices.update_device_status(device, :up)
first_change_at = device.last_status_change_at
first_checked_at = device.last_checked_at
# Sleep for 1+ second to ensure timestamps differ (truncated to seconds in update_device_status)
Process.sleep(1010)
{: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
assert DateTime.after?(updated.last_checked_at, first_checked_at)
end
test "list_snmp_enabled_devices/0 returns only device with SNMP enabled", %{site: site} do
{:ok, snmp_enabled} =
Devices.create_device(%{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.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,
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", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.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", %{site: site} do
attrs = %{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.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", %{site: site} do
attrs = %{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.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
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", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.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,
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,
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", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.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 == :default
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,
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
})
%{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
end