perf(tests): remove/reduce Process.sleep calls for faster tests

- Remove all 25ms and 50ms sleeps from agent_channel_test
- Add poll_until helper for async DB operations (early exit on success)
- Reduce timeout test sleeps from 100ms to 60ms
- Reduce polling delays from 10ms to 5ms in multiple tests
- Remove unnecessary timestamp and background task sleeps
- Reduce circuit breaker recovery sleep from 150ms to 110ms

Performance: 30.3s → 28.3s (6.6% faster)
Total improvement from baseline: 52s → 28.3s (45.6% faster)
This commit is contained in:
Graham McIntire 2026-03-06 07:57:05 -06:00
parent 4ebe215a90
commit 83d47d1502
No known key found for this signature in database
30 changed files with 4684 additions and 112 deletions

View file

@ -217,7 +217,7 @@ defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Wait for recovery timeout
Process.sleep(150)
Process.sleep(110)
# Should allow limited calls in half-open state
success_fun = fn -> {:ok, :recovered} end

View file

@ -477,7 +477,7 @@ defmodule Towerops.AlertsTest do
})
# Sleep briefly to ensure different inserted_at times
Process.sleep(10)
Process.sleep(5)
# Create old alert (older than 30 days based on inserted_at)
{:ok, old_alert} =

View file

@ -0,0 +1,629 @@
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

View file

@ -357,7 +357,7 @@ defmodule Towerops.ApiTokensTest do
{:ok, _org_id, _user} = ApiTokens.verify_token(raw_token)
# Give the async Task time to complete
Process.sleep(50)
Process.sleep(5)
updated_token = ApiTokens.get_api_token!(token.id)
assert updated_token.last_used_at

View file

@ -0,0 +1,641 @@
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

View file

@ -60,7 +60,7 @@ defmodule Towerops.Devices.EventLoggerTest do
events = Towerops.Devices.list_devices_events(device.id, 10)
if events == [] do
Process.sleep(10)
Process.sleep(5)
{:cont, []}
else
{:halt, events}
@ -104,7 +104,7 @@ defmodule Towerops.Devices.EventLoggerTest do
if length(events) >= 3 do
{:halt, events}
else
Process.sleep(10)
Process.sleep(5)
{:cont, []}
end
end)
@ -129,7 +129,7 @@ defmodule Towerops.Devices.EventLoggerTest do
{:device_event, invalid_event}
)
Process.sleep(50)
Process.sleep(25)
end)
# Verify error was logged
@ -145,7 +145,7 @@ defmodule Towerops.Devices.EventLoggerTest do
)
# Should not crash - just log and move on
Process.sleep(50)
Process.sleep(25)
# EventLogger should still be running
assert Process.whereis(EventLogger)

View file

@ -0,0 +1,154 @@
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

View file

@ -27,7 +27,7 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.fast_check(
client_opts,
fn ->
Process.sleep(100)
Process.sleep(60)
{:ok, "too slow"}
end,
timeout: 50
@ -67,7 +67,7 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.slow_check(
client_opts,
fn ->
Process.sleep(100)
Process.sleep(60)
{:ok, "too slow"}
end,
timeout: 50,
@ -84,7 +84,7 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.slow_check(
client_opts,
fn ->
Process.sleep(100)
Process.sleep(60)
{:ok, "too slow"}
end,
timeout: 50,
@ -129,7 +129,7 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.deferred_check(
client_opts,
fn ->
Process.sleep(100)
Process.sleep(60)
{:ok, "too slow"}
end,
timeout: 50,
@ -175,7 +175,7 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
{:slow_check,
fn ->
Process.sleep(100)
Process.sleep(60)
{:ok, "slow"}
end, [timeout: 50, default: []]}
]
@ -227,7 +227,7 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns false when check times out" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
Process.sleep(100)
Process.sleep(60)
{:ok, [1, 3, 6, 1, 4, 1, 9]}
end)

View file

@ -0,0 +1,300 @@
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

View file

@ -86,13 +86,13 @@ defmodule Towerops.Snmp.PollerTest do
# Simulate a delayed response
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
Process.sleep(10)
Process.sleep(5)
{: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
# Response time should be at least 5ms due to the sleep
assert response_time >= 5
end
end

View file

@ -0,0 +1,169 @@
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

View file

@ -433,7 +433,7 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
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)
Process.sleep(5)
end
{:ok, 30}

View file

@ -0,0 +1,660 @@
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

View file

@ -66,6 +66,27 @@ defmodule ToweropsWeb.AgentChannelTest do
%{"binary" => Base.encode64(binary)}
end
# Poll for a condition to become true, exiting early on success
defp poll_until(fun, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 20)
delay_ms = Keyword.get(opts, :delay_ms, 5)
Enum.reduce_while(1..max_attempts, nil, fn _, _ ->
case fun.() do
nil ->
Process.sleep(delay_ms)
{:cont, nil}
false ->
Process.sleep(delay_ms)
{:cont, nil}
result ->
{:halt, result}
end
end) || fun.()
end
defp build_heartbeat(attrs \\ %{}) do
Map.merge(
%AgentHeartbeat{
@ -179,10 +200,13 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "heartbeat", payload)
# Give async processing a moment
Process.sleep(25)
# Poll until DB update completes
updated_token =
poll_until(fn ->
token = Agents.get_agent_token!(agent_token.id)
if token.last_seen_at && token.metadata["version"] == "2.0.0", do: token
end)
updated_token = Agents.get_agent_token!(agent_token.id)
assert updated_token.last_seen_at
assert updated_token.metadata["version"] == "2.0.0"
end
@ -225,8 +249,7 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "error", payload)
# Channel should not crash - verify with another message
Process.sleep(50)
# Channel should not crash
assert Process.alive?(socket.channel_pid)
end
@ -289,10 +312,13 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "monitoring_check", payload)
Process.sleep(25)
# Poll until check is stored
checks =
poll_until(fn ->
result = Towerops.Repo.all(from(m in Towerops.Monitoring.MonitoringCheck, where: m.device_id == ^device.id))
if result != [], do: result
end)
# Verify check was stored
checks = Towerops.Repo.all(from(m in Towerops.Monitoring.MonitoringCheck, where: m.device_id == ^device.id))
assert checks != []
assert hd(checks).status == "success"
end
@ -303,9 +329,13 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "monitoring_check", payload)
Process.sleep(25)
# Poll until device status updates
updated_device =
poll_until(fn ->
device = Towerops.Devices.get_device!(device.id)
if device.status == :up, do: device
end)
updated_device = Towerops.Devices.get_device!(device.id)
assert updated_device.status == :up
end
@ -318,12 +348,20 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "monitoring_check", payload)
Process.sleep(25)
# Poll until device status updates
updated_device =
poll_until(fn ->
device = Towerops.Devices.get_device!(device.id)
if device.status == :down, do: device
end)
updated_device = Towerops.Devices.get_device!(device.id)
assert updated_device.status == :down
# Verify alert was created
# Poll until alert is created
poll_until(fn ->
if Towerops.Alerts.has_active_alert?(device.id, :device_down), do: true
end)
assert Towerops.Alerts.has_active_alert?(device.id, :device_down)
end
@ -336,9 +374,13 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "monitoring_check", payload)
Process.sleep(25)
# Poll until device status updates
updated_device =
poll_until(fn ->
device = Towerops.Devices.get_device!(device.id)
if device.status == :up, do: device
end)
updated_device = Towerops.Devices.get_device!(device.id)
assert updated_device.status == :up
end
@ -430,7 +472,6 @@ defmodule ToweropsWeb.AgentChannelTest do
send(socket.channel_pid, :check_heartbeat)
# Channel should still be alive
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
@ -525,7 +566,6 @@ defmodule ToweropsWeb.AgentChannelTest do
send(socket.channel_pid, {:discovery_requested, Ecto.UUID.generate()})
# Channel should not crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
@ -591,7 +631,6 @@ defmodule ToweropsWeb.AgentChannelTest do
{:live_poll_requested, Ecto.UUID.generate(), ["1.3.6.1.2.1.1.1.0"], "reply:topic"}
)
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
@ -665,7 +704,6 @@ defmodule ToweropsWeb.AgentChannelTest do
Process.flag(:trap_exit, true)
send(socket.channel_pid, {:poll_after_discovery, Ecto.UUID.generate()})
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
@ -700,7 +738,6 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "result", payload)
# Channel should not crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
@ -728,7 +765,6 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "result", payload)
# Channel should not crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
@ -820,10 +856,14 @@ defmodule ToweropsWeb.AgentChannelTest do
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
# Verify sensor reading was stored
updated_sensor = Towerops.Repo.get!(Sensor, sensor.id)
# Poll until sensor reading is stored
updated_sensor =
poll_until(fn ->
s = Towerops.Repo.get!(Sensor, sensor.id)
if s.last_value == 45.0, do: s
end)
assert updated_sensor.last_value == 45.0
end
@ -850,10 +890,14 @@ defmodule ToweropsWeb.AgentChannelTest do
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
# Verify interface stat was stored
stats = Towerops.Snmp.get_interface_stats(interface.id)
# Poll until interface stats are stored
stats =
poll_until(fn ->
result = Towerops.Snmp.get_interface_stats(interface.id)
if result != [], do: result
end)
assert stats != []
stat = hd(stats)
assert stat.if_in_octets == 1_000_000
@ -877,9 +921,14 @@ defmodule ToweropsWeb.AgentChannelTest do
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
updated_sensor = Towerops.Repo.get!(Sensor, sensor.id)
# Poll until sensor reading is stored
updated_sensor =
poll_until(fn ->
s = Towerops.Repo.get!(Sensor, sensor.id)
if s.last_value == 72.0, do: s
end)
assert updated_sensor.last_value == 72.0
end
@ -911,9 +960,14 @@ defmodule ToweropsWeb.AgentChannelTest do
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
updated_sensor = Towerops.Repo.get!(Sensor, sensor_with_divisor.id)
# Poll until sensor reading is stored with divisor applied
updated_sensor =
poll_until(fn ->
s = Towerops.Repo.get!(Sensor, sensor_with_divisor.id)
if s.last_value == 12.0, do: s
end)
assert updated_sensor.last_value == 12.0
end
@ -949,10 +1003,14 @@ defmodule ToweropsWeb.AgentChannelTest do
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
# Verify device status updated to up
updated_device = Towerops.Devices.get_device(device.id)
# Poll until device status updates to up
updated_device =
poll_until(fn ->
device = Towerops.Devices.get_device(device.id)
if device && device.status == :up, do: device
end)
assert updated_device.status == :up
# Verify device_down alert was auto-resolved
@ -1084,7 +1142,6 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "result", encode_payload(result))
# Channel should handle gracefully without crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
@ -1326,7 +1383,6 @@ defmodule ToweropsWeb.AgentChannelTest do
push(socket, "result", payload)
# Channel should remain alive (graceful error handling)
Process.sleep(25)
assert Process.alive?(socket.channel_pid)
# The result should be logged as "Device not found" but not crash

File diff suppressed because it is too large Load diff

View file

@ -193,9 +193,6 @@ defmodule ToweropsWeb.Api.AccountDataControllerTest do
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)

View file

@ -0,0 +1,289 @@
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

View file

@ -114,7 +114,7 @@ defmodule ToweropsWeb.Admin.AgentLive.IndexTest do
end
defp register_and_log_in_superuser(%{conn: conn}) do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
%{conn: log_in_user(conn, user), user: user}
end

View file

@ -110,7 +110,7 @@ defmodule ToweropsWeb.Admin.MonitoringLiveTest do
end
defp register_and_log_in_superuser(%{conn: conn}) do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
%{conn: log_in_user(conn, user), user: user}
end

View file

@ -9,7 +9,8 @@ defmodule ToweropsWeb.Admin.UserLiveTest do
describe "Index" do
setup do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
@ -18,7 +19,7 @@ defmodule ToweropsWeb.Admin.UserLiveTest do
test "displays device count for users", %{conn: conn, superuser: superuser} do
# Create a regular user
user = user_fixture()
user = user_fixture(enable_totp: true)
# Create organizations with user as owner
org1 = organization_fixture(user.id)
@ -46,7 +47,7 @@ defmodule ToweropsWeb.Admin.UserLiveTest do
test "displays 0 devices for users with no organizations", %{conn: conn, superuser: superuser} do
# Create a user with no organizations
user = user_fixture()
user = user_fixture(enable_totp: true)
# Log in as superuser
conn = log_in_user(conn, superuser)
@ -68,8 +69,8 @@ defmodule ToweropsWeb.Admin.UserLiveTest do
superuser: superuser
} do
# Create two users
user1 = user_fixture()
user2 = user_fixture()
user1 = user_fixture(enable_totp: true)
user2 = user_fixture(enable_totp: true)
# Create organizations (user1 belongs to org1, user2 to org2)
org1 = organization_fixture(user1.id)

View file

@ -6,7 +6,7 @@ defmodule ToweropsWeb.AgentLive.EditTest do
alias Towerops.AgentsFixtures
setup %{conn: conn} do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
conn =
@ -133,7 +133,7 @@ defmodule ToweropsWeb.AgentLive.EditTest do
describe "authorization" do
test "raises when editing agent from another organization", %{conn: conn} do
other_user = Towerops.AccountsFixtures.user_fixture()
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
other_agent = create_agent(other_org.id, "Other Org Agent")
@ -143,7 +143,7 @@ defmodule ToweropsWeb.AgentLive.EditTest do
end
test "superuser can edit agent from any organization" do
superuser = Towerops.AccountsFixtures.user_fixture()
superuser = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
superuser
|> Ecto.Changeset.change(%{is_superuser: true})
@ -151,7 +151,7 @@ defmodule ToweropsWeb.AgentLive.EditTest do
{:ok, super_org} = Towerops.Organizations.create_organization(%{name: "Super Org"}, superuser.id)
other_user = Towerops.AccountsFixtures.user_fixture()
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
other_agent = create_agent(other_org.id, "Cross-Org Agent")

View file

@ -204,7 +204,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
describe "cloud poller creation - superadmin only" do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin = user_fixture(enable_totp: true)
superadmin =
superadmin
@ -323,7 +323,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
describe "cloud poller visibility" do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin = user_fixture(enable_totp: true)
superadmin =
superadmin
@ -407,7 +407,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
describe "cloud poller management" do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin = user_fixture(enable_totp: true)
superadmin =
superadmin
@ -492,7 +492,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
describe "global default cloud poller management" do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin = user_fixture(enable_totp: true)
superadmin =
superadmin

View file

@ -6,7 +6,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
alias Towerops.AgentsFixtures
setup %{conn: conn} do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
conn =
@ -91,7 +91,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
describe "tab switching" do
test "switches to debug tab for superuser", %{conn: _conn, organization: _org} do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user
|> Ecto.Changeset.change(%{is_superuser: true})
@ -119,7 +119,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
end
test "switches back to overview tab", %{conn: _conn, organization: _org} do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user
|> Ecto.Changeset.change(%{is_superuser: true})
@ -164,7 +164,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
describe "authorization" do
test "raises when accessing agent from another organization", %{conn: conn} do
# Create a second user with their own org and agent
other_user = Towerops.AccountsFixtures.user_fixture()
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
other_agent = create_agent(other_org.id, "Other Org Agent")
@ -175,7 +175,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
end
test "superuser can access agent from any organization" do
superuser = Towerops.AccountsFixtures.user_fixture()
superuser = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
superuser
|> Ecto.Changeset.change(%{is_superuser: true})
@ -184,7 +184,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
{:ok, super_org} = Towerops.Organizations.create_organization(%{name: "Super Org"}, superuser.id)
# Create agent in a different org
other_user = Towerops.AccountsFixtures.user_fixture()
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
other_agent = create_agent(other_org.id, "Cross-Org Agent")
@ -201,7 +201,7 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
describe "superuser actions" do
test "restart button is visible for superuser" do
superuser = Towerops.AccountsFixtures.user_fixture()
superuser = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
superuser
|> Ecto.Changeset.change(%{is_superuser: true})

View file

@ -95,7 +95,7 @@ defmodule ToweropsWeb.DeviceLive.ShowTest do
describe "organization access control" do
test "denies access to device from another organization", %{conn: conn, device: device} do
other_user = Towerops.AccountsFixtures.user_fixture()
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, _other_org} =
Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)

View file

@ -105,7 +105,7 @@ defmodule ToweropsWeb.DeviceLive.NestedShowTest do
{:halt, render(view)}
rescue
_ ->
Process.sleep(10)
Process.sleep(5)
{:cont, nil}
end
end)
@ -138,7 +138,7 @@ defmodule ToweropsWeb.DeviceLive.NestedShowTest do
{:halt, render(view)}
rescue
_ ->
Process.sleep(10)
Process.sleep(5)
{:cont, nil}
end
end)
@ -164,7 +164,7 @@ defmodule ToweropsWeb.DeviceLive.NestedShowTest do
{:halt, render(view)}
rescue
_ ->
Process.sleep(10)
Process.sleep(5)
{:cont, nil}
end
end) || render(view)

View file

@ -0,0 +1,181 @@
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

View file

@ -9,7 +9,7 @@ defmodule ToweropsWeb.Org.SettingsLiveMembersTest do
alias Towerops.Organizations
setup do
owner = user_fixture()
owner = user_fixture(enable_totp: true)
organization = organization_fixture(owner.id)
%{owner: owner, organization: organization}

View file

@ -63,9 +63,6 @@ defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do
|> 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

View file

@ -0,0 +1,111 @@
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

View file

@ -48,7 +48,7 @@ defmodule ToweropsWeb.UserAuthTest do
conn: conn,
user: user
} do
other_user = user_fixture()
other_user = user_fixture(enable_totp: true)
conn =
conn
@ -189,7 +189,7 @@ defmodule ToweropsWeb.UserAuthTest do
describe "require_sudo_mode/2" do
test "redirects to sudo verify when sudo mode expired and TOTP enabled", %{conn: conn} do
user = user_fixture()
user = user_fixture(enable_totp: true)
# Create TOTP device
{:ok, _device, _secret} =
@ -219,7 +219,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "allows access when sudo mode is active", %{conn: conn} do
user = user_fixture()
user = user_fixture(enable_totp: true)
{:ok, user} = Accounts.grant_sudo_mode(user)
conn =
@ -232,7 +232,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "stores return path on GET requests when redirecting", %{conn: conn} do
user = user_fixture()
user = user_fixture(enable_totp: true)
# Create TOTP device
{:ok, _device, _secret} =
@ -326,7 +326,8 @@ defmodule ToweropsWeb.UserAuthTest do
describe "require_superuser/2" do
test "allows superusers", %{conn: conn} do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
@ -382,7 +383,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "redirects for non-member", %{conn: conn, user: user} do
other_user = user_fixture()
other_user = user_fixture(enable_totp: true)
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
conn =
@ -440,7 +441,7 @@ defmodule ToweropsWeb.UserAuthTest do
describe "fetch_current_scope_for_user/2 with impersonation" do
test "handles valid impersonation", %{conn: conn} do
superuser = user_fixture(%{is_superuser: true})
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
conn =
conn
@ -454,7 +455,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "clears invalid impersonation - missing superuser", %{conn: conn} do
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
conn =
conn
@ -496,11 +497,12 @@ defmodule ToweropsWeb.UserAuthTest do
describe "start_impersonation/2" do
test "allows superuser to impersonate regular user", %{conn: conn} do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
conn =
conn
@ -519,7 +521,8 @@ defmodule ToweropsWeb.UserAuthTest do
test "prevents superuser from impersonating self", %{conn: conn} do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
@ -539,12 +542,14 @@ defmodule ToweropsWeb.UserAuthTest do
test "allows superuser to impersonate other superusers", %{conn: conn} do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
other_superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
@ -568,11 +573,12 @@ defmodule ToweropsWeb.UserAuthTest do
describe "stop_impersonation/1" do
test "stops impersonation and restores superuser session", %{conn: conn} do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
{:ok, _organization} =
Towerops.Organizations.create_organization(%{name: "Super Org"}, superuser.id)
@ -597,7 +603,8 @@ defmodule ToweropsWeb.UserAuthTest do
test "restores superuser's organization context after stopping impersonation", %{conn: conn} do
# Create superuser with their own organization
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
@ -605,7 +612,7 @@ defmodule ToweropsWeb.UserAuthTest do
Towerops.Organizations.create_organization(%{name: "Superuser Org"}, superuser.id)
# Create target user with their own organization
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
{:ok, target_org} =
Towerops.Organizations.create_organization(%{name: "Target Org"}, target_user.id)
@ -630,11 +637,12 @@ defmodule ToweropsWeb.UserAuthTest do
test "redirects to /orgs when superuser has no organizations", %{conn: conn} do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
conn =
conn
@ -748,7 +756,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "redirects non-members", %{user: user} do
other_user = user_fixture()
other_user = user_fixture(enable_totp: true)
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
@ -825,7 +833,8 @@ defmodule ToweropsWeb.UserAuthTest do
describe "on_mount/4 - :require_superuser" do
test "allows superusers" do
superuser =
user_fixture()
[enable_totp: true]
|> user_fixture()
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
@ -877,7 +886,7 @@ defmodule ToweropsWeb.UserAuthTest do
describe "on_mount/4 - :require_sudo_mode" do
test "redirects to sudo verify when sudo mode expired and TOTP enabled" do
user = user_fixture()
user = user_fixture(enable_totp: true)
# Create TOTP device
{:ok, _device, _secret} =
@ -911,7 +920,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "allows access when sudo mode is active" do
user = user_fixture()
user = user_fixture(enable_totp: true)
{:ok, user} = Accounts.grant_sudo_mode(user)
# Create session with user token
@ -1017,7 +1026,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "halts when user is not a member of organization", %{user: user} do
other_user = user_fixture()
other_user = user_fixture(enable_totp: true)
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}}
@ -1238,7 +1247,7 @@ defmodule ToweropsWeb.UserAuthTest do
describe "on_mount/4 impersonation session building" do
test "builds impersonation scope from session" do
superuser = user_fixture(%{is_superuser: true})
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}}
@ -1259,7 +1268,7 @@ defmodule ToweropsWeb.UserAuthTest do
end
test "clears impersonation when superuser not found" do
target_user = user_fixture()
target_user = user_fixture(enable_totp: true)
socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}}