diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs
index ecd7f379..878fd698 100644
--- a/test/towerops/devices_test.exs
+++ b/test/towerops/devices_test.exs
@@ -1752,4 +1752,604 @@ defmodule Towerops.EquipmentTest do
refute_receive {:device_status_changed, _}
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
diff --git a/test/towerops/workers/device_monitor_worker_test.exs b/test/towerops/workers/device_monitor_worker_test.exs
index 49761393..05c21e62 100644
--- a/test/towerops/workers/device_monitor_worker_test.exs
+++ b/test/towerops/workers/device_monitor_worker_test.exs
@@ -4,6 +4,7 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
import Towerops.AccountsFixtures
alias Towerops.Devices
+ alias Towerops.Monitoring.PingMock
alias Towerops.Organizations
alias Towerops.Sites
alias Towerops.Workers.DeviceMonitorWorker
@@ -87,4 +88,78 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
refute cancelled_jobs == []
end
end
+
+ describe "perform/1" do
+ test "skips check for deleted device" do
+ fake_id = Ecto.UUID.generate()
+ assert :ok == DeviceMonitorWorker.perform(%Oban.Job{args: %{"device_id" => fake_id}})
+ end
+
+ test "performs check and reschedules for monitoring-enabled device", %{site: site} do
+ {:ok, device} =
+ Devices.create_device(%{
+ name: "Monitor Me",
+ ip_address: "192.168.1.10",
+ site_id: site.id,
+ organization_id: site.organization_id,
+ monitoring_enabled: true
+ })
+
+ Mox.expect(PingMock, :ping, fn _ip -> {:ok, 5.0} end)
+
+ assert :ok == DeviceMonitorWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
+
+ # Device should be marked up
+ updated = Devices.get_device!(device.id)
+ assert updated.status == :up
+ end
+
+ test "handles ping failure and marks device down", %{site: site} do
+ {:ok, device} =
+ Devices.create_device(%{
+ name: "Failing Device",
+ ip_address: "192.168.1.11",
+ site_id: site.id,
+ organization_id: site.organization_id,
+ monitoring_enabled: true
+ })
+
+ Mox.expect(PingMock, :ping, fn _ip -> {:error, :timeout} end)
+
+ assert :ok == DeviceMonitorWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
+
+ updated = Devices.get_device!(device.id)
+ assert updated.status == :down
+ end
+
+ test "does not check when monitoring is disabled", %{site: site} do
+ {:ok, device} =
+ Devices.create_device(%{
+ name: "No Monitor",
+ ip_address: "192.168.1.12",
+ site_id: site.id,
+ organization_id: site.organization_id,
+ monitoring_enabled: false
+ })
+
+ # No mock expectation - ping should not be called
+ assert :ok == DeviceMonitorWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
+ end
+ end
+
+ describe "trigger_check/1" do
+ test "inserts an immediate job", %{site: site} do
+ {:ok, device} =
+ Devices.create_device(%{
+ name: "Trigger Me",
+ ip_address: "192.168.1.13",
+ site_id: site.id,
+ organization_id: site.organization_id,
+ monitoring_enabled: true
+ })
+
+ assert {:ok, job} = DeviceMonitorWorker.trigger_check(device.id)
+ assert job.args["device_id"] == device.id
+ end
+ end
end
diff --git a/test/towerops/workers/discovery_worker_test.exs b/test/towerops/workers/discovery_worker_test.exs
index fb477916..5a212ff0 100644
--- a/test/towerops/workers/discovery_worker_test.exs
+++ b/test/towerops/workers/discovery_worker_test.exs
@@ -132,6 +132,39 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
end
+ describe "enqueue/1" do
+ setup do
+ user = user_fixture()
+ {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Enqueue Org"}, user.id)
+
+ {:ok, site} =
+ Towerops.Sites.create_site(%{
+ name: "Enqueue Site",
+ organization_id: organization.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Enqueue Device",
+ ip_address: "192.168.1.50",
+ snmp_enabled: true,
+ site_id: site.id,
+ organization_id: organization.id
+ })
+
+ {:ok, device: device}
+ end
+
+ test "enqueues a discovery job", %{device: device} do
+ assert :ok = DiscoveryWorker.enqueue(device.id)
+ end
+
+ test "deduplicates jobs within 60 seconds", %{device: device} do
+ assert :ok = DiscoveryWorker.enqueue(device.id)
+ assert :ok = DiscoveryWorker.enqueue(device.id)
+ end
+ end
+
describe "retryable_error?/1" do
test "timeout is retryable" do
assert DiscoveryWorker.retryable_error?(:timeout)
@@ -157,6 +190,14 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
refute DiscoveryWorker.retryable_error?(:invalid_config)
end
+ test "no_snmp_credentials is not retryable" do
+ refute DiscoveryWorker.retryable_error?(:no_snmp_credentials)
+ end
+
+ test "device_deleted is not retryable" do
+ refute DiscoveryWorker.retryable_error?(:device_deleted)
+ end
+
test "unknown errors are retryable by default" do
assert DiscoveryWorker.retryable_error?(:some_unknown_error)
end
diff --git a/test/towerops/workers/firmware_version_fetcher_worker_test.exs b/test/towerops/workers/firmware_version_fetcher_worker_test.exs
index 6cccb307..6acc4eff 100644
--- a/test/towerops/workers/firmware_version_fetcher_worker_test.exs
+++ b/test/towerops/workers/firmware_version_fetcher_worker_test.exs
@@ -128,6 +128,67 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorkerTest do
assert is_nil(data.release_date)
end
+ test "extracts version number from 'RouterOS X.Y.Z [stable]' format" do
+ rss_with_full_version = """
+
+
+
+ RouterOS latest stable version
+ -
+ RouterOS 7.21.2 [stable]
+ https://mikrotik.com/download
+ Wed, 15 Jan 2025 12:00:00 +0000
+ RouterOS 7.21.2 stable release
+
+
+
+ """
+
+ assert {:ok, data} = FirmwareVersionFetcherWorker.parse_rss_feed(rss_with_full_version)
+ assert data.version == "7.21.2"
+ assert data.metadata["raw_version"] == "RouterOS 7.21.2 [stable]"
+ end
+
+ test "extracts two-part version number" do
+ rss_with_two_part = """
+
+
+
+ RouterOS latest stable version
+ -
+ RouterOS 7.21 [stable]
+ https://mikrotik.com/download
+ Wed, 15 Jan 2025 12:00:00 +0000
+ Test
+
+
+
+ """
+
+ assert {:ok, data} = FirmwareVersionFetcherWorker.parse_rss_feed(rss_with_two_part)
+ assert data.version == "7.21"
+ end
+
+ test "handles missing pubDate" do
+ rss_no_date = """
+
+
+
+ RouterOS latest stable version
+ -
+ 7.14.1
+ https://mikrotik.com/download
+ Test
+
+
+
+ """
+
+ assert {:ok, data} = FirmwareVersionFetcherWorker.parse_rss_feed(rss_no_date)
+ assert data.version == "7.14.1"
+ assert is_nil(data.release_date)
+ end
+
test "stores metadata correctly" do
rss_with_metadata = """
diff --git a/test/towerops_web/controllers/user_reset_password_controller_test.exs b/test/towerops_web/controllers/user_reset_password_controller_test.exs
index f7c48323..fb2a4ed1 100644
--- a/test/towerops_web/controllers/user_reset_password_controller_test.exs
+++ b/test/towerops_web/controllers/user_reset_password_controller_test.exs
@@ -3,6 +3,8 @@ defmodule ToweropsWeb.UserResetPasswordControllerTest do
import Towerops.AccountsFixtures
+ # Note: edit/update routes are handled by UserResetPasswordLive, not this controller
+
setup do
%{user: user_fixture(enable_totp: false)}
end
diff --git a/test/towerops_web/live/account_live/activity_test.exs b/test/towerops_web/live/account_live/activity_test.exs
index 6d889aee..b8789667 100644
--- a/test/towerops_web/live/account_live/activity_test.exs
+++ b/test/towerops_web/live/account_live/activity_test.exs
@@ -26,5 +26,152 @@ defmodule ToweropsWeb.AccountLive.ActivityTest do
assert html =~ "User data viewed"
end
+
+ test "shows sudo_mode_granted from setup", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+
+ assert html =~ "Sudo mode granted"
+ end
+ end
+
+ describe "action descriptions" do
+ test "displays user_data_exported action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "user_data_exported",
+ superuser_id: user.id,
+ target_user_id: user.id
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "exported your account data"
+ end
+
+ test "displays user_profile_updated action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "user_profile_updated",
+ superuser_id: user.id,
+ target_user_id: user.id
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "updated your profile"
+ end
+
+ test "displays device_created action with metadata", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "device_created",
+ superuser_id: user.id,
+ target_user_id: user.id,
+ metadata: %{"device_name" => "Test Router"}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "Test Router"
+ end
+
+ test "displays device_deleted action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "device_deleted",
+ superuser_id: user.id,
+ target_user_id: user.id,
+ metadata: %{"device_name" => "Old Router"}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "Old Router"
+ end
+
+ test "displays device_updated action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "device_updated",
+ superuser_id: user.id,
+ target_user_id: user.id,
+ metadata: %{"device_id" => "abc-123"}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "abc-123"
+ end
+
+ test "displays failed_access_attempt action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "failed_access_attempt",
+ superuser_id: user.id,
+ target_user_id: user.id,
+ metadata: %{"resource" => "/admin/secret"}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "/admin/secret"
+ end
+
+ test "displays privilege_escalation action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "privilege_escalation",
+ superuser_id: user.id,
+ target_user_id: user.id,
+ metadata: %{"from_role" => "member", "to_role" => "admin"}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "member"
+ assert html =~ "admin"
+ end
+
+ test "displays impersonate_start action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "impersonate_start",
+ superuser_id: user.id,
+ target_user_id: user.id
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "impersonating"
+ end
+
+ test "displays impersonate_end action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "impersonate_end",
+ superuser_id: user.id,
+ target_user_id: user.id
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "stopped impersonating"
+ end
+
+ test "displays org_data_accessed action", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "org_data_accessed",
+ superuser_id: user.id,
+ target_user_id: user.id,
+ metadata: %{"organization_id" => "org-123", "action" => "exported"}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "org-123"
+ end
+
+ test "displays fallback for action without handler", %{conn: conn, user: user} do
+ {:ok, _} =
+ Admin.create_audit_log(%{
+ action: "monitoring_data_queried",
+ superuser_id: user.id,
+ target_user_id: user.id
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/account/activity")
+ assert html =~ "Monitoring data queried"
+ end
end
end