chore: remove backup files from sed operations
This commit is contained in:
parent
83d47d1502
commit
0c7d195e52
10 changed files with 0 additions and 4512 deletions
|
|
@ -1,629 +0,0 @@
|
|||
defmodule Towerops.AlertsTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.Alerts
|
||||
|
||||
describe "alerts" do
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Alerts.Alert
|
||||
|
||||
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} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Router 1",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
%{device: device, organization: organization, user: user}
|
||||
end
|
||||
|
||||
@valid_attrs %{
|
||||
alert_type: "device_down",
|
||||
triggered_at: ~U[2025-12-21 12:00:00Z],
|
||||
message: "Equipment is not responding"
|
||||
}
|
||||
|
||||
test "create_alert/1 with valid data creates an alert", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
assert {:ok, %Alert{} = alert} = Alerts.create_alert(attrs)
|
||||
assert alert.alert_type == "device_down"
|
||||
assert alert.message == "Equipment is not responding"
|
||||
end
|
||||
|
||||
test "create_alert/1 with invalid data returns error changeset" do
|
||||
assert {:error, %Ecto.Changeset{}} = Alerts.create_alert(%{})
|
||||
end
|
||||
|
||||
test "list_devices_alerts/2 returns alerts for device", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
[result] = Alerts.list_devices_alerts(device.id)
|
||||
assert result.id == alert.id
|
||||
end
|
||||
|
||||
test "list_organization_active_alerts/1 returns unresolved alerts", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
|
||||
# Create resolved alert
|
||||
{:ok, resolved_alert} =
|
||||
Alerts.create_alert(Map.put(attrs, :resolved_at, ~U[2025-12-21 13:00:00Z]))
|
||||
|
||||
active = Alerts.list_organization_active_alerts(organization.id)
|
||||
assert length(active) == 1
|
||||
assert hd(active).id == alert.id
|
||||
refute Enum.any?(active, fn a -> a.id == resolved_alert.id end)
|
||||
end
|
||||
|
||||
test "list_organization_alerts/2 returns all alerts", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, _alert1} = Alerts.create_alert(attrs)
|
||||
{:ok, _alert2} = Alerts.create_alert(Map.put(attrs, :alert_type, :device_up))
|
||||
|
||||
alerts = Alerts.list_organization_alerts(organization.id, 100)
|
||||
assert length(alerts) == 2
|
||||
end
|
||||
|
||||
test "get_alert!/1 returns the alert with given id", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
assert Alerts.get_alert!(alert.id).id == alert.id
|
||||
end
|
||||
|
||||
test "acknowledge_alert/2 marks alert as acknowledged", %{device: device, user: user} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
|
||||
assert {:ok, acknowledged} = Alerts.acknowledge_alert(alert, user.id)
|
||||
assert acknowledged.acknowledged_at
|
||||
assert acknowledged.acknowledged_by_id == user.id
|
||||
end
|
||||
|
||||
test "resolve_alert/1 marks alert as resolved", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
|
||||
assert {:ok, resolved} = Alerts.resolve_alert(alert)
|
||||
assert resolved.resolved_at
|
||||
end
|
||||
|
||||
test "has_active_alert?/2 returns true when active alert exists", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, _alert} = Alerts.create_alert(attrs)
|
||||
|
||||
assert Alerts.has_active_alert?(device.id, :device_down) == true
|
||||
assert Alerts.has_active_alert?(device.id, :device_up) == false
|
||||
end
|
||||
|
||||
test "has_active_alert?/2 returns false for resolved alerts", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
{:ok, _resolved} = Alerts.resolve_alert(alert)
|
||||
|
||||
assert Alerts.has_active_alert?(device.id, :device_down) == false
|
||||
end
|
||||
|
||||
test "get_active_alert/2 returns active alert of specific type", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
|
||||
found = Alerts.get_active_alert(device.id, :device_down)
|
||||
assert found.id == alert.id
|
||||
end
|
||||
|
||||
test "get_active_alert/2 returns nil when no active alert exists", %{device: device} do
|
||||
assert Alerts.get_active_alert(device.id, :device_down) == nil
|
||||
end
|
||||
|
||||
test "get_active_alert/2 returns nil for resolved alerts", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
{:ok, _resolved} = Alerts.resolve_alert(alert)
|
||||
|
||||
assert Alerts.get_active_alert(device.id, :device_down) == nil
|
||||
end
|
||||
|
||||
test "count_active_alerts/1 returns count of unresolved alerts", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, _alert1} = Alerts.create_alert(attrs)
|
||||
{:ok, _alert2} = Alerts.create_alert(attrs)
|
||||
|
||||
# Create resolved alert
|
||||
{:ok, resolved} = Alerts.create_alert(attrs)
|
||||
{:ok, _} = Alerts.resolve_alert(resolved)
|
||||
|
||||
assert Alerts.count_active_alerts(organization.id) == 2
|
||||
end
|
||||
|
||||
test "count_active_alerts/1 returns 0 when no active alerts", %{organization: organization} do
|
||||
assert Alerts.count_active_alerts(organization.id) == 0
|
||||
end
|
||||
|
||||
test "list_devices_alerts/2 respects limit parameter", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
# Create 5 alerts
|
||||
for _i <- 1..5 do
|
||||
{:ok, _} = Alerts.create_alert(attrs)
|
||||
end
|
||||
|
||||
# Request only 3
|
||||
alerts = Alerts.list_devices_alerts(device.id, 3)
|
||||
assert length(alerts) == 3
|
||||
end
|
||||
|
||||
test "list_devices_alerts/2 orders by triggered_at descending", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
{:ok, old_alert} =
|
||||
Alerts.create_alert(Map.put(attrs, :triggered_at, ~U[2025-12-21 10:00:00Z]))
|
||||
|
||||
{:ok, new_alert} =
|
||||
Alerts.create_alert(Map.put(attrs, :triggered_at, ~U[2025-12-21 12:00:00Z]))
|
||||
|
||||
[first, second] = Alerts.list_devices_alerts(device.id)
|
||||
assert first.id == new_alert.id
|
||||
assert second.id == old_alert.id
|
||||
end
|
||||
|
||||
test "list_organization_alerts/2 with status filter active", %{
|
||||
device: device,
|
||||
organization: organization,
|
||||
user: user
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
# Create unacknowledged alert
|
||||
{:ok, active_alert} = Alerts.create_alert(attrs)
|
||||
|
||||
# Create acknowledged alert
|
||||
{:ok, acked_alert} = Alerts.create_alert(attrs)
|
||||
{:ok, _} = Alerts.acknowledge_alert(acked_alert, user.id)
|
||||
|
||||
# Create resolved alert
|
||||
{:ok, resolved_alert} = Alerts.create_alert(attrs)
|
||||
{:ok, _} = Alerts.resolve_alert(resolved_alert)
|
||||
|
||||
active = Alerts.list_organization_alerts(organization.id, %{"status" => "active"})
|
||||
assert length(active) == 1
|
||||
assert hd(active).id == active_alert.id
|
||||
end
|
||||
|
||||
test "list_organization_alerts/2 with status filter acknowledged", %{
|
||||
device: device,
|
||||
organization: organization,
|
||||
user: user
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
# Create acknowledged alert
|
||||
{:ok, acked_alert} = Alerts.create_alert(attrs)
|
||||
{:ok, _} = Alerts.acknowledge_alert(acked_alert, user.id)
|
||||
|
||||
# Create unacknowledged alert
|
||||
{:ok, _active} = Alerts.create_alert(attrs)
|
||||
|
||||
acknowledged =
|
||||
Alerts.list_organization_alerts(organization.id, %{"status" => "acknowledged"})
|
||||
|
||||
assert length(acknowledged) == 1
|
||||
assert hd(acknowledged).id == acked_alert.id
|
||||
end
|
||||
|
||||
test "list_organization_alerts/2 with status filter resolved", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
# Create resolved alert
|
||||
{:ok, resolved_alert} = Alerts.create_alert(attrs)
|
||||
{:ok, _} = Alerts.resolve_alert(resolved_alert)
|
||||
|
||||
# Create active alert
|
||||
{:ok, _active} = Alerts.create_alert(attrs)
|
||||
|
||||
resolved = Alerts.list_organization_alerts(organization.id, %{"status" => "resolved"})
|
||||
assert length(resolved) == 1
|
||||
assert hd(resolved).id == resolved_alert.id
|
||||
end
|
||||
|
||||
test "list_organization_alerts/2 with limit in filters map", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
for _i <- 1..5 do
|
||||
{:ok, _} = Alerts.create_alert(attrs)
|
||||
end
|
||||
|
||||
alerts = Alerts.list_organization_alerts(organization.id, %{"limit" => 2})
|
||||
assert length(alerts) == 2
|
||||
end
|
||||
|
||||
test "list_organization_alerts/2 defaults to limit 100", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
# Just verify it doesn't error with no limit specified
|
||||
{:ok, _} = Alerts.create_alert(attrs)
|
||||
|
||||
alerts = Alerts.list_organization_alerts(organization.id, %{})
|
||||
assert length(alerts) == 1
|
||||
end
|
||||
|
||||
test "list_organization_active_alerts/1 only returns device_down alerts", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
# Create device_down alert
|
||||
{:ok, down_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Create device_up alert
|
||||
{:ok, _up_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_up",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
active = Alerts.list_organization_active_alerts(organization.id)
|
||||
assert length(active) == 1
|
||||
assert hd(active).id == down_alert.id
|
||||
assert hd(active).alert_type == "device_down"
|
||||
end
|
||||
|
||||
test "list_organization_active_alerts/1 preloads associations", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, _alert} = Alerts.create_alert(attrs)
|
||||
|
||||
[alert] = Alerts.list_organization_active_alerts(organization.id)
|
||||
assert Ecto.assoc_loaded?(alert.device)
|
||||
assert Ecto.assoc_loaded?(alert.device.site)
|
||||
end
|
||||
|
||||
test "get_alert!/1 preloads associations", %{device: device} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
{:ok, alert} = Alerts.create_alert(attrs)
|
||||
|
||||
fetched = Alerts.get_alert!(alert.id)
|
||||
assert Ecto.assoc_loaded?(fetched.device)
|
||||
assert Ecto.assoc_loaded?(fetched.acknowledged_by)
|
||||
end
|
||||
|
||||
test "get_alert!/1 raises when alert doesn't exist" do
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
Alerts.get_alert!(Ecto.UUID.generate())
|
||||
end
|
||||
end
|
||||
|
||||
test "get_active_alert/2 returns most recent when multiple active alerts", %{
|
||||
device: device
|
||||
} do
|
||||
attrs = Map.put(@valid_attrs, :device_id, device.id)
|
||||
|
||||
{:ok, old_alert} =
|
||||
Alerts.create_alert(Map.put(attrs, :triggered_at, ~U[2025-12-21 10:00:00Z]))
|
||||
|
||||
{:ok, new_alert} =
|
||||
Alerts.create_alert(Map.put(attrs, :triggered_at, ~U[2025-12-21 12:00:00Z]))
|
||||
|
||||
found = Alerts.get_active_alert(device.id, :device_down)
|
||||
assert found.id == new_alert.id
|
||||
refute found.id == old_alert.id
|
||||
end
|
||||
|
||||
test "count_active_alerts/1 only counts device_down alerts", %{
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
# Create device_down alert
|
||||
{:ok, _} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Create device_up alert (should not be counted)
|
||||
{:ok, _} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_up",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
assert Alerts.count_active_alerts(organization.id) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "GDPR data access - list_alerts_for_organizations/2" 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
|
||||
})
|
||||
|
||||
{:ok, device1} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Device 1",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site1.id,
|
||||
organization_id: org1.id
|
||||
})
|
||||
|
||||
{:ok, device2} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Device 2",
|
||||
ip_address: "192.168.1.2",
|
||||
site_id: site2.id,
|
||||
organization_id: org2.id
|
||||
})
|
||||
|
||||
%{org1: org1, org2: org2, device1: device1, device2: device2}
|
||||
end
|
||||
|
||||
test "returns all alerts for given organizations", %{
|
||||
org1: org1,
|
||||
org2: org2,
|
||||
device1: device1,
|
||||
device2: device2
|
||||
} do
|
||||
{:ok, alert1} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device 1 down"
|
||||
})
|
||||
|
||||
{:ok, alert2} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device2.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device 2 down"
|
||||
})
|
||||
|
||||
# Default limit is 90 days
|
||||
result = Alerts.list_alerts_for_organizations([org1.id, org2.id])
|
||||
assert length(result) == 2
|
||||
|
||||
alert_ids = Enum.map(result, & &1.id)
|
||||
assert alert1.id in alert_ids
|
||||
assert alert2.id in alert_ids
|
||||
end
|
||||
|
||||
test "returns only alerts from specified organizations", %{
|
||||
org1: org1,
|
||||
device1: device1,
|
||||
device2: device2
|
||||
} do
|
||||
{:ok, alert1} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
{:ok, _alert2} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device2.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Only request alerts from org1
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert length(result) == 1
|
||||
assert hd(result).id == alert1.id
|
||||
end
|
||||
|
||||
test "respects since parameter to filter old alerts", %{org1: org1, device1: device1} do
|
||||
# Create recent alert (within 30 days)
|
||||
{:ok, recent_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Sleep briefly to ensure different inserted_at times
|
||||
Process.sleep(10)
|
||||
|
||||
# Create old alert (older than 30 days based on inserted_at)
|
||||
{:ok, old_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Manually set old alert's inserted_at to 60 days ago
|
||||
sixty_days_ago = DateTime.utc_now() |> DateTime.add(-60, :day) |> DateTime.truncate(:second)
|
||||
|
||||
old_alert
|
||||
|> Ecto.Changeset.change(inserted_at: sixty_days_ago)
|
||||
|> Towerops.Repo.update!()
|
||||
|
||||
# Request only alerts from last 30 days
|
||||
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day)
|
||||
result = Alerts.list_alerts_for_organizations([org1.id], since: thirty_days_ago)
|
||||
assert length(result) == 1
|
||||
assert hd(result).id == recent_alert.id
|
||||
end
|
||||
|
||||
test "returns all alerts when no since parameter provided", %{org1: org1, device1: device1} do
|
||||
# Create two alerts with different ages
|
||||
{:ok, recent_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
{:ok, old_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.add(DateTime.utc_now(), -120, :day)
|
||||
})
|
||||
|
||||
# Without since parameter, should return all alerts
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert length(result) == 2
|
||||
|
||||
alert_ids = Enum.map(result, & &1.id)
|
||||
assert recent_alert.id in alert_ids
|
||||
assert old_alert.id in alert_ids
|
||||
end
|
||||
|
||||
test "returns empty list when organizations have no alerts", %{org1: org1} do
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert result == []
|
||||
end
|
||||
|
||||
test "returns empty list for empty organization list" do
|
||||
result = Alerts.list_alerts_for_organizations([])
|
||||
assert result == []
|
||||
end
|
||||
|
||||
test "preloads device and site associations", %{org1: org1, device1: device1} do
|
||||
{:ok, _alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert length(result) == 1
|
||||
loaded_alert = hd(result)
|
||||
assert Ecto.assoc_loaded?(loaded_alert.device)
|
||||
assert Ecto.assoc_loaded?(loaded_alert.device.site)
|
||||
end
|
||||
|
||||
test "orders alerts by triggered_at descending", %{org1: org1, device1: device1} do
|
||||
{:ok, old_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.add(DateTime.utc_now(), -10, :day)
|
||||
})
|
||||
|
||||
{:ok, new_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.add(DateTime.utc_now(), -5, :day)
|
||||
})
|
||||
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert length(result) == 2
|
||||
|
||||
# Most recent should be first
|
||||
[first, second] = result
|
||||
assert first.id == new_alert.id
|
||||
assert second.id == old_alert.id
|
||||
end
|
||||
|
||||
test "includes both active and resolved alerts", %{org1: org1, device1: device1} do
|
||||
# Create active alert
|
||||
{:ok, active_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Create resolved alert
|
||||
{:ok, resolved_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
{:ok, _} = Alerts.resolve_alert(resolved_alert)
|
||||
|
||||
# Should include both
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert length(result) == 2
|
||||
|
||||
alert_ids = Enum.map(result, & &1.id)
|
||||
assert active_alert.id in alert_ids
|
||||
assert resolved_alert.id in alert_ids
|
||||
end
|
||||
|
||||
test "includes all alert types", %{org1: org1, device1: device1} do
|
||||
{:ok, _down_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
{:ok, _up_alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device1.id,
|
||||
alert_type: "device_up",
|
||||
triggered_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
result = Alerts.list_alerts_for_organizations([org1.id])
|
||||
assert length(result) == 2
|
||||
|
||||
alert_types = Enum.map(result, & &1.alert_type)
|
||||
assert "device_down" in alert_types
|
||||
assert "device_up" in alert_types
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
defmodule Towerops.ApiTokensTest do
|
||||
use Towerops.DataCase
|
||||
use ExUnitProperties
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
alias Towerops.ApiTokens.ApiToken
|
||||
|
||||
describe "create_api_token/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{user: user, organization: organization}
|
||||
end
|
||||
|
||||
test "creates an API token with valid attributes", %{
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
attrs = %{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
}
|
||||
|
||||
assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
|
||||
assert api_token.name == "Test Token"
|
||||
assert api_token.organization_id == organization.id
|
||||
assert api_token.user_id == user.id
|
||||
assert is_binary(api_token.token_hash)
|
||||
assert String.starts_with?(raw_token, "towerops_")
|
||||
assert is_nil(api_token.last_used_at)
|
||||
assert is_nil(api_token.expires_at)
|
||||
end
|
||||
|
||||
test "creates an API token without user_id", %{organization: organization} do
|
||||
attrs = %{
|
||||
organization_id: organization.id,
|
||||
name: "Test Token"
|
||||
}
|
||||
|
||||
assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
|
||||
assert api_token.name == "Test Token"
|
||||
assert api_token.organization_id == organization.id
|
||||
assert is_nil(api_token.user_id)
|
||||
assert is_binary(api_token.token_hash)
|
||||
assert String.starts_with?(raw_token, "towerops_")
|
||||
end
|
||||
|
||||
test "creates an API token with expiration", %{
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
|
||||
|
||||
attrs = %{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Expiring Token",
|
||||
expires_at: expires_at
|
||||
}
|
||||
|
||||
assert {:ok, {api_token, _raw_token}} = ApiTokens.create_api_token(attrs)
|
||||
assert api_token.expires_at == DateTime.truncate(expires_at, :second)
|
||||
end
|
||||
|
||||
test "returns error with invalid attributes" do
|
||||
assert {:error, %Ecto.Changeset{}} = ApiTokens.create_api_token(%{})
|
||||
end
|
||||
|
||||
test "returns error with blank name", %{organization: organization} do
|
||||
attrs = %{
|
||||
organization_id: organization.id,
|
||||
name: ""
|
||||
}
|
||||
|
||||
assert {:error, changeset} = ApiTokens.create_api_token(attrs)
|
||||
assert "can't be blank" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "returns error without organization_id" do
|
||||
attrs = %{
|
||||
name: "Test Token"
|
||||
}
|
||||
|
||||
assert {:error, changeset} = ApiTokens.create_api_token(attrs)
|
||||
assert "can't be blank" in errors_on(changeset).organization_id
|
||||
end
|
||||
|
||||
test "generates unique tokens", %{user: user, organization: organization} do
|
||||
attrs = %{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
}
|
||||
|
||||
{:ok, {_token1, raw_token1}} = ApiTokens.create_api_token(attrs)
|
||||
{:ok, {_token2, raw_token2}} = ApiTokens.create_api_token(attrs)
|
||||
|
||||
assert raw_token1 != raw_token2
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_organization_api_tokens/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{user: user, organization: organization}
|
||||
end
|
||||
|
||||
test "returns all tokens for an organization", %{
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, {token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 1"
|
||||
})
|
||||
|
||||
{:ok, {token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 2"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_organization_api_tokens(organization.id)
|
||||
|
||||
assert length(tokens) == 2
|
||||
assert Enum.any?(tokens, fn t -> t.id == token1.id end)
|
||||
assert Enum.any?(tokens, fn t -> t.id == token2.id end)
|
||||
end
|
||||
|
||||
test "returns tokens ordered by creation date", %{
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, {token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 1"
|
||||
})
|
||||
|
||||
{:ok, {token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 2"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_organization_api_tokens(organization.id)
|
||||
|
||||
assert length(tokens) == 2
|
||||
# Verify tokens are ordered by inserted_at (descending)
|
||||
[first, second] = tokens
|
||||
|
||||
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
|
||||
# Verify both tokens are present
|
||||
token_ids = Enum.map(tokens, & &1.id)
|
||||
assert token1.id in token_ids
|
||||
assert token2.id in token_ids
|
||||
end
|
||||
|
||||
test "returns empty list for organization with no tokens", %{organization: organization} do
|
||||
assert [] = ApiTokens.list_organization_api_tokens(organization.id)
|
||||
end
|
||||
|
||||
test "does not return tokens from other organizations", %{user: user, organization: org1} do
|
||||
org2 = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: org1.id,
|
||||
user_id: user.id,
|
||||
name: "Org 1 Token"
|
||||
})
|
||||
|
||||
{:ok, {_token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: org2.id,
|
||||
user_id: user.id,
|
||||
name: "Org 2 Token"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_organization_api_tokens(org1.id)
|
||||
|
||||
assert length(tokens) == 1
|
||||
assert hd(tokens).name == "Org 1 Token"
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_user_api_tokens/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{user: user, organization: organization}
|
||||
end
|
||||
|
||||
test "returns all tokens created by a user", %{user: user, organization: organization} do
|
||||
{:ok, {token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 1"
|
||||
})
|
||||
|
||||
{:ok, {token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 2"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_user_api_tokens(user.id)
|
||||
|
||||
assert length(tokens) == 2
|
||||
assert Enum.any?(tokens, fn t -> t.id == token1.id end)
|
||||
assert Enum.any?(tokens, fn t -> t.id == token2.id end)
|
||||
end
|
||||
|
||||
test "preloads organization", %{user: user, organization: organization} do
|
||||
{:ok, {_token, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 1"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_user_api_tokens(user.id)
|
||||
|
||||
assert [token] = tokens
|
||||
assert %Ecto.Association.NotLoaded{} != token.organization
|
||||
assert token.organization.id == organization.id
|
||||
end
|
||||
|
||||
test "returns tokens ordered by creation date", %{
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, {token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 1"
|
||||
})
|
||||
|
||||
{:ok, {token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 2"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_user_api_tokens(user.id)
|
||||
|
||||
assert length(tokens) == 2
|
||||
# Verify tokens are ordered by inserted_at (descending)
|
||||
[first, second] = tokens
|
||||
|
||||
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
|
||||
# Verify both tokens are present
|
||||
token_ids = Enum.map(tokens, & &1.id)
|
||||
assert token1.id in token_ids
|
||||
assert token2.id in token_ids
|
||||
end
|
||||
|
||||
test "returns empty list for user with no tokens", %{user: user} do
|
||||
assert [] = ApiTokens.list_user_api_tokens(user.id)
|
||||
end
|
||||
|
||||
test "does not return tokens created by other users", %{organization: organization} do
|
||||
user1 = user_fixture()
|
||||
user2 = user_fixture()
|
||||
|
||||
{:ok, {_token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user1.id,
|
||||
name: "User 1 Token"
|
||||
})
|
||||
|
||||
{:ok, {_token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user2.id,
|
||||
name: "User 2 Token"
|
||||
})
|
||||
|
||||
tokens = ApiTokens.list_user_api_tokens(user1.id)
|
||||
|
||||
assert length(tokens) == 1
|
||||
assert hd(tokens).name == "User 1 Token"
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_api_token!/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {token, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
%{token: token}
|
||||
end
|
||||
|
||||
test "returns the token with the given id", %{token: token} do
|
||||
fetched_token = ApiTokens.get_api_token!(token.id)
|
||||
assert fetched_token.id == token.id
|
||||
assert fetched_token.name == token.name
|
||||
end
|
||||
|
||||
test "raises if the token does not exist" do
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
ApiTokens.get_api_token!(Ecto.UUID.generate())
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "verify_token/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
%{token: token, raw_token: raw_token, organization: organization, user: user}
|
||||
end
|
||||
|
||||
test "returns organization_id and user for valid token", %{
|
||||
raw_token: raw_token,
|
||||
organization: organization,
|
||||
user: user
|
||||
} do
|
||||
assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
|
||||
assert org_id == organization.id
|
||||
assert returned_user.id == user.id
|
||||
end
|
||||
|
||||
test "updates last_used_at timestamp", %{token: token, raw_token: raw_token} do
|
||||
assert is_nil(token.last_used_at)
|
||||
|
||||
{:ok, _org_id, _user} = ApiTokens.verify_token(raw_token)
|
||||
|
||||
# Give the async Task time to complete
|
||||
Process.sleep(50)
|
||||
|
||||
updated_token = ApiTokens.get_api_token!(token.id)
|
||||
assert updated_token.last_used_at
|
||||
assert DateTime.diff(updated_token.last_used_at, DateTime.utc_now(), :second) <= 1
|
||||
end
|
||||
|
||||
test "returns error for invalid token" do
|
||||
assert {:error, :invalid_token} = ApiTokens.verify_token("invalid_token")
|
||||
end
|
||||
|
||||
test "returns error for expired token", %{
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
expires_at = DateTime.add(DateTime.utc_now(), -1, :day)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Expired Token",
|
||||
expires_at: expires_at
|
||||
})
|
||||
|
||||
assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
|
||||
end
|
||||
|
||||
test "works for token without expiration", %{
|
||||
raw_token: raw_token,
|
||||
organization: organization
|
||||
} do
|
||||
assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
|
||||
assert org_id == organization.id
|
||||
end
|
||||
|
||||
test "works for token without user_id", %{organization: organization} do
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
name: "No User Token"
|
||||
})
|
||||
|
||||
assert {:ok, org_id, nil} = ApiTokens.verify_token(raw_token)
|
||||
assert org_id == organization.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_api_token/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {token, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
%{token: token}
|
||||
end
|
||||
|
||||
test "deletes the token", %{token: token} do
|
||||
assert {:ok, %ApiToken{}} = ApiTokens.delete_api_token(token)
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
ApiTokens.get_api_token!(token.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_api_token/2" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {token, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
%{token: token}
|
||||
end
|
||||
|
||||
test "updates the token name", %{token: token} do
|
||||
assert {:ok, updated_token} = ApiTokens.update_api_token(token, %{name: "New Name"})
|
||||
assert updated_token.name == "New Name"
|
||||
end
|
||||
|
||||
test "updates the expiration", %{token: token} do
|
||||
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
|
||||
|
||||
assert {:ok, updated_token} =
|
||||
ApiTokens.update_api_token(token, %{expires_at: expires_at})
|
||||
|
||||
assert updated_token.expires_at == DateTime.truncate(expires_at, :second)
|
||||
end
|
||||
|
||||
test "returns error with invalid name", %{token: token} do
|
||||
assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: ""})
|
||||
assert "can't be blank" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "returns error with too long name", %{token: token} do
|
||||
long_name = String.duplicate("a", 256)
|
||||
assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: long_name})
|
||||
assert "should be at most 255 character(s)" in errors_on(changeset).name
|
||||
end
|
||||
end
|
||||
|
||||
describe "property-based tests" do
|
||||
property "generated tokens always start with towerops_" do
|
||||
check all(_ <- constant(nil), max_runs: 50) do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
assert String.starts_with?(raw_token, "towerops_")
|
||||
# Token should be sufficiently long (prefix + base64 encoded 32 bytes)
|
||||
assert String.length(raw_token) > 40
|
||||
end
|
||||
end
|
||||
|
||||
property "tokens with valid names can always be created" do
|
||||
check all(name <- string(:alphanumeric, min_length: 1, max_length: 255), max_runs: 10) do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
result =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: name
|
||||
})
|
||||
|
||||
case result do
|
||||
{:ok, {token, raw_token}} ->
|
||||
assert token.name == name
|
||||
assert String.starts_with?(raw_token, "towerops_")
|
||||
|
||||
{:error, _changeset} ->
|
||||
flunk("Valid name '#{name}' was rejected")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
property "verify_token roundtrip always works for non-expired tokens" do
|
||||
check all(_ <- constant(nil), max_runs: 20) do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
# Verify the token we just created
|
||||
assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
|
||||
assert org_id == organization.id
|
||||
assert returned_user.id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
property "expired tokens always return error" do
|
||||
# Create fixtures once outside the property check
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
check all(days_ago <- integer(1..365), max_runs: 20) do
|
||||
expires_at = DateTime.add(DateTime.utc_now(), -days_ago, :day)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Expired Token",
|
||||
expires_at: expires_at
|
||||
})
|
||||
|
||||
assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
|
||||
end
|
||||
end
|
||||
|
||||
property "list operations return consistent counts" do
|
||||
check all(token_count <- integer(0..5), max_runs: 10) do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
# Create specified number of tokens
|
||||
if token_count > 0 do
|
||||
for i <- 1..token_count do
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token #{i}"
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
org_tokens = ApiTokens.list_organization_api_tokens(organization.id)
|
||||
user_tokens = ApiTokens.list_user_api_tokens(user.id)
|
||||
|
||||
assert length(org_tokens) == token_count
|
||||
assert length(user_tokens) == token_count
|
||||
assert length(org_tokens) == length(user_tokens)
|
||||
end
|
||||
end
|
||||
|
||||
property "deleting tokens reduces count by one" do
|
||||
check all(_ <- constant(nil), max_runs: 20) do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
# Create two tokens
|
||||
{:ok, {token1, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 1"
|
||||
})
|
||||
|
||||
{:ok, {_token2, _}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Token 2"
|
||||
})
|
||||
|
||||
initial_count = length(ApiTokens.list_organization_api_tokens(organization.id))
|
||||
assert initial_count == 2
|
||||
|
||||
# Delete one token
|
||||
{:ok, _} = ApiTokens.delete_api_token(token1)
|
||||
|
||||
final_count = length(ApiTokens.list_organization_api_tokens(organization.id))
|
||||
assert final_count == initial_count - 1
|
||||
end
|
||||
end
|
||||
|
||||
property "updating token name preserves token hash" do
|
||||
check all(
|
||||
original_name <- string(:alphanumeric, min_length: 1, max_length: 50),
|
||||
new_name <- string(:alphanumeric, min_length: 1, max_length: 50),
|
||||
max_runs: 10
|
||||
) do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: original_name
|
||||
})
|
||||
|
||||
original_hash = token.token_hash
|
||||
|
||||
# Update the name
|
||||
{:ok, updated_token} = ApiTokens.update_api_token(token, %{name: new_name})
|
||||
|
||||
# Token hash should remain the same (name changes don't affect token)
|
||||
assert updated_token.token_hash == original_hash
|
||||
assert updated_token.name == new_name
|
||||
|
||||
# Original raw token should still work
|
||||
assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
|
||||
assert org_id == organization.id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
defmodule Towerops.Devices.EventLoggerTest do
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Devices.EventLogger
|
||||
|
||||
describe "event logging via PubSub" do
|
||||
setup do
|
||||
# Start EventLogger for these tests
|
||||
start_supervised!(EventLogger)
|
||||
|
||||
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} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Equipment",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
ip_address: "192.168.1.1"
|
||||
})
|
||||
|
||||
%{user: user, organization: organization, site: site, device: device}
|
||||
end
|
||||
|
||||
test "EventLogger logs events broadcast via PubSub", %{device: device} do
|
||||
event_attrs = %{
|
||||
device_id: device.id,
|
||||
event_type: "interface_speed_change",
|
||||
severity: "info",
|
||||
message: "Interface eth0 speed detected: 1.0 Gbps",
|
||||
metadata: %{
|
||||
interface_id: "test-interface-id",
|
||||
interface_name: "eth0",
|
||||
old_speed: nil,
|
||||
new_speed: 1_000_000_000
|
||||
},
|
||||
occurred_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
# Broadcast event via PubSub
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:events",
|
||||
{:device_event, event_attrs}
|
||||
)
|
||||
|
||||
# Poll for event with early exit (max 100ms)
|
||||
events =
|
||||
Enum.reduce_while(1..10, [], fn _, _acc ->
|
||||
events = Towerops.Devices.list_devices_events(device.id, 10)
|
||||
|
||||
if events == [] do
|
||||
Process.sleep(10)
|
||||
{:cont, []}
|
||||
else
|
||||
{:halt, events}
|
||||
end
|
||||
end)
|
||||
|
||||
assert [_event] = events
|
||||
|
||||
event = hd(events)
|
||||
assert event.device_id == device.id
|
||||
assert event.event_type == "interface_speed_change"
|
||||
assert event.severity == "info"
|
||||
assert event.message == "Interface eth0 speed detected: 1.0 Gbps"
|
||||
end
|
||||
|
||||
test "EventLogger handles multiple events in sequence", %{device: device} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
# Broadcast multiple events
|
||||
for i <- 1..3 do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:events",
|
||||
{:device_event,
|
||||
%{
|
||||
device_id: device.id,
|
||||
event_type: "interface_speed_change",
|
||||
severity: "info",
|
||||
message: "Event #{i}",
|
||||
metadata: %{},
|
||||
occurred_at: DateTime.add(now, i, :second)
|
||||
}}
|
||||
)
|
||||
end
|
||||
|
||||
# Poll for events with early exit (max 100ms)
|
||||
events =
|
||||
Enum.reduce_while(1..10, [], fn _, _acc ->
|
||||
events = Towerops.Devices.list_devices_events(device.id, 10)
|
||||
|
||||
if length(events) >= 3 do
|
||||
{:halt, events}
|
||||
else
|
||||
Process.sleep(10)
|
||||
{:cont, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Verify all events were created
|
||||
assert length(events) == 3
|
||||
end
|
||||
|
||||
test "EventLogger logs errors for invalid events", %{device: device} do
|
||||
# Broadcast an invalid event (missing required fields)
|
||||
invalid_event = %{
|
||||
device_id: device.id
|
||||
# Missing required fields
|
||||
}
|
||||
|
||||
# Capture log output
|
||||
log =
|
||||
ExUnit.CaptureLog.capture_log(fn ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:events",
|
||||
{:device_event, invalid_event}
|
||||
)
|
||||
|
||||
Process.sleep(50)
|
||||
end)
|
||||
|
||||
# Verify error was logged
|
||||
assert log =~ "Failed to log event"
|
||||
end
|
||||
|
||||
test "EventLogger ignores non-event messages" do
|
||||
# Send a message that's not an event
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:events",
|
||||
{:some_other_message, "data"}
|
||||
)
|
||||
|
||||
# Should not crash - just log and move on
|
||||
Process.sleep(50)
|
||||
|
||||
# EventLogger should still be running
|
||||
assert Process.whereis(EventLogger)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.DeferredDiscovery
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "fast_check/3" do
|
||||
test "returns result when check completes within timeout" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.fast_check(client_opts, fn ->
|
||||
{:ok, "test result"}
|
||||
end)
|
||||
|
||||
assert result == {:ok, "test result"}
|
||||
end
|
||||
|
||||
test "returns error when check times out" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.fast_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(100)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50
|
||||
)
|
||||
|
||||
assert result == {:error, :timeout}
|
||||
end
|
||||
|
||||
test "returns error result from check function" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.fast_check(client_opts, fn ->
|
||||
{:error, :connection_failed}
|
||||
end)
|
||||
|
||||
assert result == {:error, :connection_failed}
|
||||
end
|
||||
end
|
||||
|
||||
describe "slow_check/3" do
|
||||
test "returns result when check completes" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.slow_check(client_opts, fn ->
|
||||
{:ok, ["sensor1", "sensor2"]}
|
||||
end)
|
||||
|
||||
assert result == {:ok, ["sensor1", "sensor2"]}
|
||||
end
|
||||
|
||||
test "returns default value when check times out" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(100)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50,
|
||||
default: []
|
||||
)
|
||||
|
||||
assert result == {:ok, []}
|
||||
end
|
||||
|
||||
test "uses custom default value on timeout" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(100)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50,
|
||||
default: %{status: :timeout}
|
||||
)
|
||||
|
||||
assert result == {:ok, %{status: :timeout}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "deferred_check/3" do
|
||||
test "returns ok result when check succeeds" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.deferred_check(client_opts, fn ->
|
||||
{:ok, ["neighbor1", "neighbor2"]}
|
||||
end)
|
||||
|
||||
assert result == {:ok, ["neighbor1", "neighbor2"]}
|
||||
end
|
||||
|
||||
test "returns deferred status when check fails" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.deferred_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
{:error, :not_supported}
|
||||
end,
|
||||
name: "neighbors"
|
||||
)
|
||||
|
||||
assert {:deferred, [], :not_supported} = result
|
||||
end
|
||||
|
||||
test "returns timeout status when check times out" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.deferred_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(100)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50,
|
||||
name: "neighbors"
|
||||
)
|
||||
|
||||
assert {:timeout, []} = result
|
||||
end
|
||||
end
|
||||
|
||||
describe "parallel_checks/1" do
|
||||
test "runs multiple checks in parallel" do
|
||||
checks = [
|
||||
{:sensors, fn -> {:ok, ["temp1", "temp2"]} end, []},
|
||||
{:interfaces, fn -> {:ok, ["eth0", "eth1"]} end, []},
|
||||
{:neighbors, fn -> {:ok, ["switch1"]} end, []}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert map_size(results) == 3
|
||||
assert {:ok, ["temp1", "temp2"]} = results[:sensors]
|
||||
assert {:ok, ["eth0", "eth1"]} = results[:interfaces]
|
||||
assert {:ok, ["switch1"]} = results[:neighbors]
|
||||
end
|
||||
|
||||
test "handles mixed success and failure results" do
|
||||
checks = [
|
||||
{:sensors, fn -> {:ok, ["temp1"]} end, []},
|
||||
{:interfaces, fn -> {:error, :walk_failed} end, []},
|
||||
{:neighbors, fn -> {:ok, []} end, []}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert {:ok, ["temp1"]} = results[:sensors]
|
||||
assert {:error, :walk_failed} = results[:interfaces]
|
||||
assert {:ok, []} = results[:neighbors]
|
||||
end
|
||||
|
||||
test "handles timeout with default value" do
|
||||
checks = [
|
||||
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
|
||||
{:slow_check,
|
||||
fn ->
|
||||
Process.sleep(100)
|
||||
{:ok, "slow"}
|
||||
end, [timeout: 50, default: []]}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert {:ok, "fast"} = results[:fast_check]
|
||||
assert {:timeout, []} = results[:slow_check]
|
||||
end
|
||||
|
||||
test "handles exceptions gracefully" do
|
||||
checks = [
|
||||
{:good_check, fn -> {:ok, "good"} end, []},
|
||||
{:bad_check, fn -> raise "something went wrong" end, []}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert {:ok, "good"} = results[:good_check]
|
||||
assert {:error, "something went wrong"} = results[:bad_check]
|
||||
end
|
||||
|
||||
test "handles empty list" do
|
||||
results = DeferredDiscovery.parallel_checks([])
|
||||
assert results == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "device_responsive?/2" do
|
||||
test "returns true when device responds" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:ok, [1, 3, 6, 1, 4, 1, 9]}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.device_responsive?(client_opts) == true
|
||||
end
|
||||
|
||||
test "returns false when device does not respond" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.device_responsive?(client_opts) == false
|
||||
end
|
||||
|
||||
test "returns false when check times out" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
Process.sleep(100)
|
||||
{:ok, [1, 3, 6, 1, 4, 1, 9]}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.device_responsive?(client_opts, timeout: 50) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "categorize_device_speed/2" do
|
||||
test "returns :fast for quick responses" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:ok, "Fast Device"}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.categorize_device_speed(client_opts) == :fast
|
||||
end
|
||||
|
||||
test "returns :slow for delayed responses" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
# Sleep just past the threshold to be classified as slow
|
||||
Process.sleep(60)
|
||||
{:ok, "Slow Device"}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
# Use a shorter threshold (50ms) so 60ms delay = slow
|
||||
assert DeferredDiscovery.categorize_device_speed(client_opts, fast_threshold: 50) == :slow
|
||||
end
|
||||
|
||||
test "returns :unresponsive when device errors" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.categorize_device_speed(client_opts) == :unresponsive
|
||||
end
|
||||
end
|
||||
|
||||
describe "timeouts_for_speed/1" do
|
||||
test "returns short timeouts for fast devices" do
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(:fast)
|
||||
|
||||
assert timeouts[:fast] == 5_000
|
||||
assert timeouts[:slow] == 15_000
|
||||
assert timeouts[:deferred] == 30_000
|
||||
end
|
||||
|
||||
test "returns medium timeouts for slow devices" do
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(:slow)
|
||||
|
||||
assert timeouts[:fast] == 15_000
|
||||
assert timeouts[:slow] == 45_000
|
||||
assert timeouts[:deferred] == 90_000
|
||||
end
|
||||
|
||||
test "returns long timeouts for unresponsive devices" do
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(:unresponsive)
|
||||
|
||||
assert timeouts[:fast] == 30_000
|
||||
assert timeouts[:slow] == 60_000
|
||||
assert timeouts[:deferred] == 120_000
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
defmodule Towerops.Snmp.PollerTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.Poller
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "check_device/1" do
|
||||
test "returns response time on successful check" do
|
||||
client_opts = [
|
||||
ip: "192.168.1.1",
|
||||
community: "public",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 5000
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:ok, {:timeticks, 12_345_678}}
|
||||
end)
|
||||
|
||||
assert {:ok, response_time} = Poller.check_device(client_opts)
|
||||
assert is_integer(response_time)
|
||||
assert response_time >= 0
|
||||
end
|
||||
|
||||
test "returns error on timeout" do
|
||||
client_opts = [
|
||||
ip: "192.168.1.99",
|
||||
community: "public",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 1000
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
assert {:error, :timeout} = Poller.check_device(client_opts)
|
||||
end
|
||||
|
||||
test "returns error on authentication failure" do
|
||||
client_opts = [
|
||||
ip: "192.168.1.1",
|
||||
community: "wrong_community",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 5000
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:error, :auth_failure}
|
||||
end)
|
||||
|
||||
assert {:error, :auth_failure} = Poller.check_device(client_opts)
|
||||
end
|
||||
|
||||
test "returns error on network unreachable" do
|
||||
client_opts = [
|
||||
ip: "10.255.255.1",
|
||||
community: "public",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 5000
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:error, :network_unreachable}
|
||||
end)
|
||||
|
||||
assert {:error, :network_unreachable} = Poller.check_device(client_opts)
|
||||
end
|
||||
|
||||
test "measures response time accurately" do
|
||||
client_opts = [
|
||||
ip: "192.168.1.1",
|
||||
community: "public",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 5000
|
||||
]
|
||||
|
||||
# Simulate a delayed response
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
Process.sleep(10)
|
||||
{:ok, {:timeticks, 999}}
|
||||
end)
|
||||
|
||||
assert {:ok, response_time} = Poller.check_device(client_opts)
|
||||
# Response time should be at least 10ms due to the sleep
|
||||
assert response_time >= 10
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_client_opts/1" do
|
||||
test "builds client options from device map" do
|
||||
device = %{
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_community: "private",
|
||||
snmp_version: "2c",
|
||||
snmp_port: 161
|
||||
}
|
||||
|
||||
client_opts = Poller.build_client_opts(device)
|
||||
|
||||
assert client_opts[:ip] == "192.168.1.100"
|
||||
assert client_opts[:community] == "private"
|
||||
assert client_opts[:version] == "2c"
|
||||
assert client_opts[:port] == 161
|
||||
assert client_opts[:timeout] == 5000
|
||||
end
|
||||
|
||||
test "uses default port 161 when snmp_port is nil" do
|
||||
device = %{
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_community: "public",
|
||||
snmp_version: "2c",
|
||||
snmp_port: nil
|
||||
}
|
||||
|
||||
client_opts = Poller.build_client_opts(device)
|
||||
|
||||
assert client_opts[:port] == 161
|
||||
end
|
||||
|
||||
test "handles custom SNMP port" do
|
||||
device = %{
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_community: "public",
|
||||
snmp_version: "2c",
|
||||
snmp_port: 1161
|
||||
}
|
||||
|
||||
client_opts = Poller.build_client_opts(device)
|
||||
|
||||
assert client_opts[:port] == 1161
|
||||
end
|
||||
|
||||
test "builds options for SNMPv1" do
|
||||
device = %{
|
||||
ip_address: "10.0.0.1",
|
||||
snmp_community: "public",
|
||||
snmp_version: "1",
|
||||
snmp_port: 161
|
||||
}
|
||||
|
||||
client_opts = Poller.build_client_opts(device)
|
||||
|
||||
assert client_opts[:version] == "1"
|
||||
end
|
||||
|
||||
test "includes standard timeout of 5 seconds" do
|
||||
device = %{
|
||||
ip_address: "192.168.1.1",
|
||||
snmp_community: "public",
|
||||
snmp_version: "2c",
|
||||
snmp_port: 161
|
||||
}
|
||||
|
||||
client_opts = Poller.build_client_opts(device)
|
||||
|
||||
assert client_opts[:timeout] == 5000
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,660 +0,0 @@
|
|||
defmodule Towerops.Workers.DevicePollerWorkerTest do
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
# DevicePollerWorker is deprecated in favor of CheckExecutorWorker
|
||||
# These tests remain for documentation but are skipped
|
||||
import Mox
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.Sensor
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
alias Towerops.Snmp.StateSensor
|
||||
alias Towerops.Workers.DevicePollerWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
# Configure Mock SNMP Adapter
|
||||
old_adapter = Application.get_env(:towerops, :snmp_adapter)
|
||||
Application.put_env(:towerops, :snmp_adapter, SnmpMock)
|
||||
|
||||
# Disable Phoenix SNMP check for tests
|
||||
old_disable = Application.get_env(:towerops, :disable_phoenix_snmp)
|
||||
Application.put_env(:towerops, :disable_phoenix_snmp, false)
|
||||
|
||||
on_exit(fn ->
|
||||
if old_adapter do
|
||||
Application.put_env(:towerops, :snmp_adapter, old_adapter)
|
||||
else
|
||||
Application.delete_env(:towerops, :snmp_adapter)
|
||||
end
|
||||
|
||||
if old_disable == nil do
|
||||
Application.delete_env(:towerops, :disable_phoenix_snmp)
|
||||
else
|
||||
Application.put_env(:towerops, :disable_phoenix_snmp, old_disable)
|
||||
end
|
||||
end)
|
||||
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
%{organization: organization, site: site, user: user}
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
@describetag :skip
|
||||
|
||||
test "returns :ok when device does not exist" do
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => Ecto.UUID.generate()}})
|
||||
end
|
||||
|
||||
test "skips polling when snmp_enabled is false", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router No SNMP",
|
||||
ip_address: "192.168.1.10",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: false
|
||||
})
|
||||
|
||||
# Ensure no jobs exist initially (created by create_device)
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# FIXED: Should NOT schedule next poll when SNMP is disabled
|
||||
# This prevents zombie jobs from continuing to poll disabled devices
|
||||
# Previously, it would reschedule even when disabled, causing unnecessary work
|
||||
assert Repo.aggregate(Oban.Job, :count) == 0
|
||||
end
|
||||
|
||||
test "polls device and schedules next run", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router Poll",
|
||||
ip_address: "192.168.1.11",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 60
|
||||
})
|
||||
|
||||
# Clear existing jobs
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# Verify next job scheduled
|
||||
assert Repo.aggregate(Oban.Job, :count) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "polling logic" do
|
||||
@describetag :skip
|
||||
|
||||
test "polls sensors and updates values", %{site: site} do
|
||||
# 1. Create Device
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Sensor Device",
|
||||
ip_address: "192.168.1.50",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
# 2. Create SnmpDevice association
|
||||
{:ok, snmp_device} =
|
||||
Repo.insert(%Device{
|
||||
device_id: device.id,
|
||||
sys_object_id: "1.3.6.1.4.1.9.1",
|
||||
sys_descr: "Cisco Router",
|
||||
sys_name: "router01"
|
||||
})
|
||||
|
||||
# 3. Create Sensor
|
||||
{:ok, sensor} =
|
||||
Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Chassis Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
monitored: true
|
||||
})
|
||||
|
||||
# 4. Create Interface
|
||||
{:ok, interface} =
|
||||
Repo.insert(%Interface{
|
||||
snmp_device_id: snmp_device.id,
|
||||
if_index: 2,
|
||||
if_name: "eth0",
|
||||
if_descr: "Ethernet 0",
|
||||
if_type: 6,
|
||||
if_speed: 100_000_000,
|
||||
if_phys_address: "00:11:22:33:44:55",
|
||||
if_admin_status: "up",
|
||||
if_oper_status: "up",
|
||||
monitored: true
|
||||
})
|
||||
|
||||
# 4b. Create State Sensor
|
||||
{:ok, state_sensor} =
|
||||
Repo.insert(%StateSensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_descr: "Power Supply 1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.13.1.5.1.3.1",
|
||||
sensor_index: "1",
|
||||
metadata: %{"states" => %{"1" => "unknown", "2" => "enabled", "3" => "disabled"}}
|
||||
# status defaults to unknown
|
||||
})
|
||||
|
||||
# 4c. Create Processor
|
||||
{:ok, processor} =
|
||||
Repo.insert(%Towerops.Snmp.Processor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
processor_index: "1",
|
||||
processor_type: "hr_processor",
|
||||
description: "CPU 1"
|
||||
})
|
||||
|
||||
# 5. Mock SNMP response
|
||||
# The worker runs tasks in parallel, so we need to be careful with expectations.
|
||||
# It calls poll_device_sensors, poll_device_state_sensors, etc.
|
||||
# We only have one sensor, so poll_device_sensors will try to fetch it.
|
||||
|
||||
SnmpMock
|
||||
|> stub(:get, fn _target, oid, _opts ->
|
||||
case oid do
|
||||
# Sensor
|
||||
"1.3.6.1.2.1.99.1.1.1.4.1" -> {:ok, 45}
|
||||
# State Sensor
|
||||
# enabled/ok
|
||||
"1.3.6.1.4.1.9.9.13.1.5.1.3.1" -> {:ok, 2}
|
||||
# Processor Load
|
||||
# 25% load
|
||||
"1.3.6.1.2.1.25.3.3.1.2.1" -> {:ok, 25}
|
||||
# Interface HC In Octets
|
||||
"1.3.6.1.2.1.31.1.1.1.6.2" -> {:ok, 1000}
|
||||
# Interface HC Out Octets
|
||||
"1.3.6.1.2.1.31.1.1.1.10.2" -> {:ok, 2000}
|
||||
# Interface Errors
|
||||
"1.3.6.1.2.1.2.2.1.14.2" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.20.2" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.13.2" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.19.2" -> {:ok, 0}
|
||||
# Interface change detection OIDs (for interface 2)
|
||||
# Speed
|
||||
"1.3.6.1.2.1.2.2.1.5.2" -> {:ok, 100_000_000}
|
||||
# Physical address (MAC)
|
||||
"1.3.6.1.2.1.2.2.1.6.2" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
|
||||
# Admin status (down=2)
|
||||
"1.3.6.1.2.1.2.2.1.7.2" -> {:ok, 2}
|
||||
# Oper status (up=1)
|
||||
"1.3.6.1.2.1.2.2.1.8.2" -> {:ok, 1}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
# Allow other calls (like interfaces, neighbors etc) to return empty/ok
|
||||
|> stub(:walk, fn _target, _oid, _opts -> {:ok, []} end)
|
||||
|
||||
# 6. Run Perform
|
||||
Repo.delete_all(Oban.Job)
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# 7. Verify Sensor Updated
|
||||
updated_sensor = Repo.get(Sensor, sensor.id)
|
||||
assert updated_sensor.last_value == 45.0
|
||||
assert updated_sensor.last_checked_at
|
||||
|
||||
# 8. Verify Interface Stats
|
||||
stats = Repo.all(Towerops.Snmp.InterfaceStat)
|
||||
refute Enum.empty?(stats)
|
||||
stat = List.last(stats)
|
||||
assert stat.interface_id == interface.id
|
||||
assert stat.if_in_octets == 1000
|
||||
assert stat.if_out_octets == 2000
|
||||
|
||||
# 9. Verify Interface Changed
|
||||
updated_interface = Repo.get(Interface, interface.id)
|
||||
assert updated_interface.if_admin_status == "down"
|
||||
|
||||
# 10. Verify State Sensor
|
||||
updated_state = Repo.get(StateSensor, state_sensor.id)
|
||||
assert updated_state.state_value == 2
|
||||
assert updated_state.status == "ok"
|
||||
assert updated_state.state_descr == "enabled"
|
||||
|
||||
# 11. Verify Processor
|
||||
readings = Repo.all(Towerops.Snmp.ProcessorReading)
|
||||
refute Enum.empty?(readings)
|
||||
reading = List.last(readings)
|
||||
assert reading.processor_id == processor.id
|
||||
assert reading.load_percent == 25.0
|
||||
end
|
||||
|
||||
test "handles SNMP errors gracefully", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Error Device",
|
||||
ip_address: "192.168.1.51",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
{:ok, snmp_device} = Repo.insert(%Device{device_id: device.id})
|
||||
|
||||
{:ok, _sensor} =
|
||||
Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Chassis Temp"
|
||||
})
|
||||
|
||||
# Mock timeout
|
||||
SnmpMock
|
||||
|> expect(:get, fn _target, "1.3.6.1.2.1.99.1.1.1.4.1", _opts ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|> stub(:walk, fn _, _, _ -> {:ok, []} end)
|
||||
|> stub(:get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end
|
||||
end
|
||||
|
||||
describe "start_polling/1" do
|
||||
@describetag :skip
|
||||
|
||||
test "schedules initial job with offset", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router 1",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Get the expected offset
|
||||
expected_offset = PollingOffset.calculate_offset(device.id, 300)
|
||||
|
||||
# Start polling
|
||||
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
|
||||
# Verify job is scheduled with offset
|
||||
assert job.args["device_id"] == device.id
|
||||
assert job.scheduled_at
|
||||
|
||||
# Calculate the delay (scheduled_at - inserted_at)
|
||||
delay_seconds = DateTime.diff(job.scheduled_at, job.inserted_at, :second)
|
||||
assert delay_seconds == expected_offset
|
||||
end
|
||||
|
||||
test "schedules initial job with offset for different interval", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router 2",
|
||||
ip_address: "192.168.1.2",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
check_interval_seconds: 600
|
||||
})
|
||||
|
||||
# Get the expected offset
|
||||
expected_offset = PollingOffset.calculate_offset(device.id, 600)
|
||||
|
||||
# Start polling
|
||||
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
|
||||
# Verify job is scheduled with offset
|
||||
delay_seconds = DateTime.diff(job.scheduled_at, job.inserted_at, :second)
|
||||
assert delay_seconds == expected_offset
|
||||
end
|
||||
end
|
||||
|
||||
describe "stop_polling/1" do
|
||||
@describetag :skip
|
||||
|
||||
test "cancels all jobs for device", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router 3",
|
||||
ip_address: "192.168.1.3",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id
|
||||
})
|
||||
|
||||
# Start polling
|
||||
assert {:ok, _job} = DevicePollerWorker.start_polling(device.id)
|
||||
|
||||
# Stop polling
|
||||
assert {:ok, cancelled_jobs} = DevicePollerWorker.stop_polling(device.id)
|
||||
refute cancelled_jobs == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "race condition handling" do
|
||||
@describetag :skip
|
||||
|
||||
test "handles device deletion during poll gracefully", %{organization: org, site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Device",
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
# Perform polling on device without SNMP setup (no sensors/interfaces)
|
||||
# Should complete without crashing
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# Delete device
|
||||
Devices.delete_device(device)
|
||||
|
||||
# Verify polling with non-existent device doesn't crash
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end
|
||||
|
||||
test "discards poll results when device is reassigned to agent", %{
|
||||
organization: org,
|
||||
site: site
|
||||
} do
|
||||
# Create a local agent token (is_cloud_poller defaults to false)
|
||||
{:ok, agent_token, _token} = Towerops.Agents.create_agent_token(org.id, "Local Agent")
|
||||
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Device",
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
# Create SNMP device
|
||||
snmp_device =
|
||||
%Device{}
|
||||
|> Device.changeset(%{
|
||||
device_id: device.id,
|
||||
sys_name: "test-device",
|
||||
sys_descr: "Test Device"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
# Create a sensor
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Test Sensor",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
current_reading: 25.0
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
# Initial state: Phoenix should poll (no agent assigned)
|
||||
assert Towerops.Agents.should_phoenix_poll_device?(device)
|
||||
|
||||
# Mock SNMP to simulate slow polling
|
||||
poll_started = :erlang.monotonic_time(:millisecond)
|
||||
|
||||
expect(SnmpMock, :get, fn _, _, _ ->
|
||||
# Simulate device reassignment during poll
|
||||
if :erlang.monotonic_time(:millisecond) - poll_started < 100 do
|
||||
# First call - assign to agent
|
||||
{:ok, _} = Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
Process.sleep(50)
|
||||
end
|
||||
|
||||
{:ok, 30}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
# Perform polling - should detect reassignment and discard results
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# Verify device is now assigned to local agent
|
||||
device = Repo.reload!(device)
|
||||
refute Towerops.Agents.should_phoenix_poll_device?(device)
|
||||
end
|
||||
|
||||
test "uses fresh poll interval when rescheduling after config change", %{
|
||||
organization: org,
|
||||
site: site
|
||||
} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Device",
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 60,
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
# Change interval during "polling" (before scheduling next poll)
|
||||
{:ok, _device} = Devices.update_device(device, %{check_interval_seconds: 300})
|
||||
|
||||
# Perform polling - should use NEW interval (300s) when scheduling next poll
|
||||
# Device has no SNMP setup, so it won't actually poll but will reschedule
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# The code path re-fetches the device for fresh config before rescheduling
|
||||
end
|
||||
|
||||
test "stops rescheduling when device is deleted", %{organization: org, site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Device",
|
||||
ip_address: "192.168.1.100",
|
||||
snmp_enabled: true,
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
# Perform polling
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
# Delete device
|
||||
Devices.delete_device(device)
|
||||
|
||||
# Perform polling again - should not crash and not reschedule
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end
|
||||
end
|
||||
|
||||
describe "job uniqueness" do
|
||||
test "only one job per device exists at a time", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Unique Test",
|
||||
ip_address: "192.168.1.200",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert two jobs for the same device
|
||||
{:ok, _job1} = DevicePollerWorker.start_polling(device.id)
|
||||
{:ok, _job2} = DevicePollerWorker.start_polling(device.id)
|
||||
|
||||
# Should only have one job in the database
|
||||
job_count =
|
||||
Oban.Job
|
||||
|> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker")
|
||||
|> Repo.aggregate(:count)
|
||||
|
||||
assert job_count == 1
|
||||
end
|
||||
|
||||
test "new job replaces scheduled_at of existing job", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Replace Test",
|
||||
ip_address: "192.168.1.201",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert first job scheduled far in the future
|
||||
{:ok, job1} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new(schedule_in: 3600)
|
||||
|> Oban.insert()
|
||||
|
||||
original_scheduled_at = job1.scheduled_at
|
||||
|
||||
# Insert second job scheduled sooner
|
||||
{:ok, _job2} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new(schedule_in: 60)
|
||||
|> Oban.insert()
|
||||
|
||||
# Reload the job from DB - should have updated scheduled_at
|
||||
updated_job = Repo.get!(Oban.Job, job1.id)
|
||||
assert DateTime.before?(updated_job.scheduled_at, original_scheduled_at)
|
||||
end
|
||||
|
||||
test "self-scheduling works while job is executing", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Executing Test",
|
||||
ip_address: "192.168.1.202",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert a job and manually set it to executing state
|
||||
{:ok, job1} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new()
|
||||
|> Oban.insert()
|
||||
|
||||
# Transition the job to executing state
|
||||
Repo.update_all(
|
||||
Ecto.Query.where(Oban.Job, id: ^job1.id),
|
||||
set: [state: "executing"]
|
||||
)
|
||||
|
||||
# Insert another job for the same device (simulating self-scheduling)
|
||||
{:ok, job2} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new(schedule_in: 300)
|
||||
|> Oban.insert()
|
||||
|
||||
# Should succeed - the new job should be a different job
|
||||
assert job2.id != job1.id
|
||||
|
||||
# Should have 2 jobs: one executing, one scheduled
|
||||
job_count =
|
||||
Oban.Job
|
||||
|> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker")
|
||||
|> Repo.aggregate(:count)
|
||||
|
||||
assert job_count == 2
|
||||
end
|
||||
|
||||
test "max_attempts is 1", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Max Attempts Test",
|
||||
ip_address: "192.168.1.203",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
{:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
assert job.max_attempts == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "reliability fixes - Task.yield_many race condition" do
|
||||
@tag :skip
|
||||
test "handles Task.yield_many result count mismatch gracefully", %{site: site} do
|
||||
# SKIP: Oban.Testing API changed - perform_job now expects job struct
|
||||
# The underlying fix is implemented and working in production code
|
||||
# This test verifies the fix for the race condition where Task.yield_many
|
||||
# can return fewer results than tasks if any crash or timeout.
|
||||
# The fix adds length validation and error logging.
|
||||
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Race Condition Test",
|
||||
ip_address: "192.168.1.250",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
# Create SNMP device
|
||||
{:ok, snmp_device} =
|
||||
Repo.insert(%Device{
|
||||
device_id: device.id,
|
||||
sys_descr: "Test Device",
|
||||
manufacturer: "Test",
|
||||
model: "Test Model"
|
||||
})
|
||||
|
||||
# Add sensor
|
||||
Repo.insert!(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "1",
|
||||
sensor_descr: "Test Sensor",
|
||||
sensor_type: "temperature",
|
||||
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.3.1.3.1"
|
||||
})
|
||||
|
||||
# Verify worker runs without crashing (fix prevents crashes from mismatched results)
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,289 +0,0 @@
|
|||
defmodule ToweropsWeb.Api.AccountDataControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Admin
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
describe "GET /api/v1/account/data" do
|
||||
test "requires authentication", %{conn: _conn} do
|
||||
# Build a new conn without authentication
|
||||
conn = build_conn()
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
assert response(conn, 302)
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "returns JSON data with correct content type", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
assert response(conn, 200)
|
||||
assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
|
||||
end
|
||||
|
||||
test "sets content-disposition header for download", %{conn: conn, user: user} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
[disposition] = get_resp_header(conn, "content-disposition")
|
||||
assert disposition =~ "attachment"
|
||||
assert disposition =~ "towerops-data-#{user.id}"
|
||||
assert disposition =~ ".json"
|
||||
end
|
||||
|
||||
test "includes profile data in response", %{conn: conn, user: user} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert data["profile"]["id"] == user.id
|
||||
assert data["profile"]["email"] == user.email
|
||||
assert data["profile"]["first_name"] == user.first_name
|
||||
assert data["profile"]["last_name"] == user.last_name
|
||||
# Default timezone is "UTC" even if user record has nil
|
||||
assert data["profile"]["timezone"] in ["UTC", user.timezone]
|
||||
assert data["profile"]["is_superuser"] == user.is_superuser
|
||||
end
|
||||
|
||||
test "includes organizations data", %{conn: conn, user: user} do
|
||||
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["organizations"]) == 1
|
||||
[org_data] = data["organizations"]
|
||||
assert org_data["id"] == org.id
|
||||
assert org_data["name"] == "Test Org"
|
||||
assert org_data["role"] == "owner"
|
||||
end
|
||||
|
||||
test "includes devices data with redacted SNMP community", %{conn: conn, user: user} do
|
||||
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Router",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: org.id,
|
||||
snmp_enabled: true,
|
||||
snmp_community: "secret-community-string"
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["devices"]) == 1
|
||||
[device_data] = data["devices"]
|
||||
assert device_data["id"] == device.id
|
||||
assert device_data["name"] == "Test Router"
|
||||
assert device_data["ip_address"] == "192.168.1.1"
|
||||
# SNMP community should be redacted for security
|
||||
assert device_data["snmp_community"] == "[REDACTED]"
|
||||
assert device_data["organization"]["name"] == "Test Org"
|
||||
assert device_data["site"]["name"] == "Test Site"
|
||||
end
|
||||
|
||||
test "returns empty devices when user has no organizations", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert data["devices"] == []
|
||||
end
|
||||
|
||||
test "includes alerts data from last 90 days", %{conn: conn, user: user} do
|
||||
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Router",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
# Create a recent alert
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device down"
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["alerts"]) == 1
|
||||
[alert_data] = data["alerts"]
|
||||
assert alert_data["id"] == alert.id
|
||||
assert alert_data["alert_type"] == "device_down"
|
||||
assert alert_data["message"] == "Device down"
|
||||
assert alert_data["device"]["name"] == "Test Router"
|
||||
end
|
||||
|
||||
test "includes all alerts regardless of age (filtered by inserted_at, not triggered_at)", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Test Router",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
# Create an alert with an old triggered_at (120 days ago)
|
||||
# Note: The API filters by inserted_at (when record was created), not triggered_at
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: DateTime.add(DateTime.utc_now(), -120, :day),
|
||||
message: "Old triggered time but recent record"
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
# Alert should be included because it was recently inserted (even though triggered long ago)
|
||||
assert length(data["alerts"]) == 1
|
||||
assert hd(data["alerts"])["id"] == alert.id
|
||||
end
|
||||
|
||||
test "returns empty alerts when user has no organizations", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert data["alerts"] == []
|
||||
end
|
||||
|
||||
test "includes audit logs data", %{conn: conn, user: user} do
|
||||
# Create superuser for audit log
|
||||
superuser = user_fixture(%{superuser: true})
|
||||
|
||||
{:ok, _log} =
|
||||
Admin.create_audit_log(%{
|
||||
action: "impersonate_start",
|
||||
superuser_id: superuser.id,
|
||||
target_user_id: user.id,
|
||||
ip_address: "127.0.0.1"
|
||||
})
|
||||
|
||||
# Sleep briefly to ensure different timestamps
|
||||
Process.sleep(50)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["audit_logs"]) == 2
|
||||
|
||||
# Find the logs by action (ordering might vary slightly)
|
||||
export_log = Enum.find(data["audit_logs"], &(&1["action"] == "user_data_exported"))
|
||||
impersonate_log = Enum.find(data["audit_logs"], &(&1["action"] == "impersonate_start"))
|
||||
|
||||
# Verify export log
|
||||
assert export_log
|
||||
assert export_log["actor_email"] == user.email
|
||||
|
||||
# Verify impersonation log
|
||||
assert impersonate_log
|
||||
assert impersonate_log["target_user_id"] == user.id
|
||||
assert impersonate_log["actor_email"] == superuser.email
|
||||
end
|
||||
|
||||
test "includes export metadata", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert data["export_info"]["format"] == "JSON"
|
||||
assert data["export_info"]["gdpr_article"] == "Article 15 - Right to Access"
|
||||
assert is_binary(data["export_info"]["exported_at"])
|
||||
end
|
||||
|
||||
test "creates audit log entry for data export", %{conn: conn, user: user} do
|
||||
# Count existing audit logs
|
||||
initial_count = user.id |> Admin.list_audit_logs_for_user() |> length()
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
assert response(conn, 200)
|
||||
|
||||
# Should have one more audit log
|
||||
logs = Admin.list_audit_logs_for_user(user.id)
|
||||
assert length(logs) == initial_count + 1
|
||||
|
||||
# Most recent log should be the data export
|
||||
[latest_log | _] = logs
|
||||
assert latest_log.action == "user_data_exported"
|
||||
assert latest_log.superuser_id == user.id
|
||||
end
|
||||
|
||||
test "handles user with multiple organizations", %{conn: conn, user: user} do
|
||||
{:ok, _org1} = Organizations.create_organization(%{name: "Org 1"}, user.id)
|
||||
|
||||
# Create second org by adding membership instead of creating new one
|
||||
# (to avoid subscription limit issues in tests)
|
||||
other_user = user_fixture()
|
||||
{:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, other_user.id)
|
||||
|
||||
{:ok, _membership} =
|
||||
Organizations.create_membership(%{
|
||||
organization_id: org2.id,
|
||||
user_id: user.id,
|
||||
role: :member
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["organizations"]) == 2
|
||||
org_names = data["organizations"] |> Enum.map(& &1["name"]) |> Enum.sort()
|
||||
assert org_names == ["Org 1", "Org 2"]
|
||||
end
|
||||
|
||||
test "handles user as member (not owner) of organization", %{conn: conn, user: user} do
|
||||
# Create another user who owns the org
|
||||
owner = user_fixture()
|
||||
{:ok, org} = Organizations.create_organization(%{name: "Owner's Org"}, owner.id)
|
||||
|
||||
# Add current user as member
|
||||
{:ok, _membership} =
|
||||
Organizations.create_membership(%{
|
||||
organization_id: org.id,
|
||||
user_id: user.id,
|
||||
role: :member
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["organizations"]) == 1
|
||||
[org_data] = data["organizations"]
|
||||
assert org_data["name"] == "Owner's Org"
|
||||
assert org_data["role"] == "member"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.NestedShowTest do
|
||||
use ToweropsWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Sites
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{user: user} do
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Router",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
%{organization: organization, site: site, device: device}
|
||||
end
|
||||
|
||||
describe "Show" do
|
||||
test "displays device information", %{conn: conn, device: device, organization: _org} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
assert html =~ device.name
|
||||
assert html =~ device.ip_address
|
||||
end
|
||||
|
||||
test "displays overview tab by default", %{conn: conn, device: device, organization: _org} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
assert html =~ "Overview"
|
||||
end
|
||||
|
||||
test "displays metrics when device has checks", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: _org
|
||||
} do
|
||||
# Create some checks
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 10,
|
||||
checked_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 15,
|
||||
checked_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :failure,
|
||||
response_time_ms: nil,
|
||||
checked_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
assert html =~ "Device Information"
|
||||
end
|
||||
|
||||
test "displays empty state when no checks exist", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: _org
|
||||
} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
assert html =~ device.name
|
||||
end
|
||||
|
||||
test "refreshes data periodically", %{conn: conn, device: device, organization: _org} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
# Trigger a status change
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :failure,
|
||||
response_time_ms: nil,
|
||||
checked_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Send refresh message
|
||||
send(view.pid, :refresh_data)
|
||||
|
||||
# Poll for processing with early exit (max 50ms)
|
||||
Enum.reduce_while(1..5, nil, fn _, _ ->
|
||||
try do
|
||||
{:halt, render(view)}
|
||||
rescue
|
||||
_ ->
|
||||
Process.sleep(10)
|
||||
{:cont, nil}
|
||||
end
|
||||
end)
|
||||
|
||||
# View should still be alive
|
||||
assert render(view)
|
||||
end
|
||||
|
||||
test "requires authentication", %{device: device, organization: _org} do
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/devices/#{device.id}")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "handles equipment_status_changed event", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: _org
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
# Simulate status change event
|
||||
send(view.pid, {:device_status_changed, device.id, :up, 25})
|
||||
|
||||
# Poll for processing with early exit (max 50ms)
|
||||
Enum.reduce_while(1..5, nil, fn _, _ ->
|
||||
try do
|
||||
{:halt, render(view)}
|
||||
rescue
|
||||
_ ->
|
||||
Process.sleep(10)
|
||||
{:cont, nil}
|
||||
end
|
||||
end)
|
||||
|
||||
# View should still be alive and render
|
||||
assert render(view)
|
||||
end
|
||||
|
||||
test "handles discovery_completed event", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: _org
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||
|
||||
# Simulate discovery completed event
|
||||
send(view.pid, {:discovery_completed, device.id})
|
||||
|
||||
# Poll for processing with early exit (max 50ms)
|
||||
html =
|
||||
Enum.reduce_while(1..5, nil, fn _, _ ->
|
||||
try do
|
||||
{:halt, render(view)}
|
||||
rescue
|
||||
_ ->
|
||||
Process.sleep(10)
|
||||
{:cont, nil}
|
||||
end
|
||||
end) || render(view)
|
||||
|
||||
assert html =~ "Discovery completed"
|
||||
end
|
||||
|
||||
test "switches to different tabs", %{conn: conn, device: device, organization: _org} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=events")
|
||||
|
||||
assert html =~ device.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias ToweropsWeb.Plugs.UpdateSessionActivity
|
||||
|
||||
describe "init/1" do
|
||||
test "returns options unchanged" do
|
||||
assert UpdateSessionActivity.init([]) == []
|
||||
assert UpdateSessionActivity.init(some: :option) == [some: :option]
|
||||
end
|
||||
end
|
||||
|
||||
describe "call/2" do
|
||||
test "returns conn unchanged" do
|
||||
conn = Plug.Test.init_test_session(build_conn(), %{})
|
||||
|
||||
result = UpdateSessionActivity.call(conn, [])
|
||||
|
||||
assert result == conn
|
||||
end
|
||||
|
||||
test "spawns background task to update session activity" do
|
||||
user = user_fixture()
|
||||
{token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
|
||||
# Create browser session
|
||||
{:ok, _session} =
|
||||
Accounts.create_browser_session(%{
|
||||
user_id: user.id,
|
||||
user_token_id: user_token.id,
|
||||
device_name: "Test Browser",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Mozilla/5.0 Test",
|
||||
last_activity_at: now,
|
||||
expires_at: DateTime.add(now, 30, :day)
|
||||
})
|
||||
|
||||
# Call plug with session token - should spawn background task without blocking
|
||||
result =
|
||||
build_conn()
|
||||
|> Plug.Test.init_test_session(%{user_token: token})
|
||||
|> UpdateSessionActivity.call([])
|
||||
|
||||
# Should return immediately (not block on background task)
|
||||
assert result
|
||||
end
|
||||
|
||||
test "handles missing session token gracefully" do
|
||||
build_conn()
|
||||
|> Plug.Test.init_test_session(%{})
|
||||
|> UpdateSessionActivity.call([])
|
||||
|
||||
# Should not raise an error - test passes if no exception
|
||||
end
|
||||
|
||||
test "handles invalid session token gracefully" do
|
||||
build_conn()
|
||||
|> Plug.Test.init_test_session(%{user_token: "invalid-token-does-not-exist"})
|
||||
|> UpdateSessionActivity.call([])
|
||||
|
||||
# Wait for background task to complete
|
||||
Process.sleep(100)
|
||||
|
||||
# Should not raise an error - test passes if no exception
|
||||
end
|
||||
|
||||
test "does not block the request while updating" do
|
||||
user = user_fixture()
|
||||
{token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, _session} =
|
||||
Accounts.create_browser_session(%{
|
||||
user_id: user.id,
|
||||
user_token_id: user_token.id,
|
||||
device_name: "Test Browser",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Mozilla/5.0 Test",
|
||||
last_activity_at: now,
|
||||
expires_at: DateTime.add(now, 30, :day)
|
||||
})
|
||||
|
||||
# Time the request - should be fast even though update happens in background
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
build_conn()
|
||||
|> Plug.Test.init_test_session(%{user_token: token})
|
||||
|> UpdateSessionActivity.call([])
|
||||
|
||||
end_time = System.monotonic_time(:millisecond)
|
||||
duration = end_time - start_time
|
||||
|
||||
# Should complete very quickly since update is async
|
||||
assert duration < 200
|
||||
end
|
||||
|
||||
test "handles nil token gracefully" do
|
||||
build_conn()
|
||||
|> Plug.Test.init_test_session(%{user_token: nil})
|
||||
|> UpdateSessionActivity.call([])
|
||||
|
||||
# Should not raise an error - test passes if no exception
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue