diff --git a/lib/towerops/accounts/user_consent.ex b/lib/towerops/accounts/user_consent.ex
index 27595478..afb0f168 100644
--- a/lib/towerops/accounts/user_consent.ex
+++ b/lib/towerops/accounts/user_consent.ex
@@ -48,9 +48,6 @@ defmodule Towerops.Accounts.UserConsent do
|> validate_required([:user_id, :consent_type, :version, :granted_at])
|> validate_inclusion(:consent_type, @consent_types)
|> foreign_key_constraint(:user_id)
- |> unique_constraint([:user_id, :consent_type, :version],
- name: :user_consents_user_id_consent_type_version_index
- )
end
@doc """
@@ -64,7 +61,8 @@ defmodule Towerops.Accounts.UserConsent do
end
defp validate_not_already_revoked(changeset) do
- if get_field(changeset, :revoked_at) do
+ # Check the original struct's revoked_at, not the changeset's pending changes
+ if changeset.data.revoked_at do
add_error(changeset, :revoked_at, "consent already revoked")
else
changeset
diff --git a/lib/towerops_web/live/user_registration_live.ex b/lib/towerops_web/live/user_registration_live.ex
index 20fa686e..1bf41e94 100644
--- a/lib/towerops_web/live/user_registration_live.ex
+++ b/lib/towerops_web/live/user_registration_live.ex
@@ -243,12 +243,16 @@ defmodule ToweropsWeb.UserRegistrationLive do
/>
-
-
+
I agree to the
<.link
href={~p"/terms"}
target="_blank"
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+ onclick="event.stopPropagation()"
>
Terms of Service
diff --git a/test/towerops/accounts/user_consent_test.exs b/test/towerops/accounts/user_consent_test.exs
new file mode 100644
index 00000000..73ea7ed7
--- /dev/null
+++ b/test/towerops/accounts/user_consent_test.exs
@@ -0,0 +1,236 @@
+defmodule Towerops.Accounts.UserConsentTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+
+ alias Towerops.Accounts.UserConsent
+
+ describe "consent_types/0" do
+ test "returns list of valid consent types" do
+ types = UserConsent.consent_types()
+ assert "privacy_policy" in types
+ assert "terms_of_service" in types
+ assert length(types) == 2
+ end
+ end
+
+ describe "current_version/1" do
+ test "returns current version for privacy_policy" do
+ assert UserConsent.current_version("privacy_policy") == "1.0"
+ end
+
+ test "returns current version for terms_of_service" do
+ assert UserConsent.current_version("terms_of_service") == "1.0"
+ end
+ end
+
+ describe "grant_changeset/2" do
+ setup do
+ user = user_fixture()
+ {:ok, user: user}
+ end
+
+ test "valid attributes create valid changeset", %{user: user} do
+ attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ assert changeset.valid?
+ end
+
+ test "requires user_id" do
+ attrs = %{
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).user_id
+ end
+
+ test "requires consent_type", %{user: user} do
+ attrs = %{
+ user_id: user.id,
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).consent_type
+ end
+
+ test "requires version", %{user: user} do
+ attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).version
+ end
+
+ test "requires granted_at", %{user: user} do
+ attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "1.0"
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).granted_at
+ end
+
+ test "validates consent_type is in valid list", %{user: user} do
+ attrs = %{
+ user_id: user.id,
+ consent_type: "invalid_type",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ refute changeset.valid?
+ assert "is invalid" in errors_on(changeset).consent_type
+ end
+
+ test "validates foreign key constraint on user_id" do
+ attrs = %{
+ user_id: Ecto.UUID.generate(),
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ assert changeset.valid?
+
+ {:error, changeset} = Repo.insert(changeset)
+ assert "does not exist" in errors_on(changeset).user_id
+ end
+
+ test "allows multiple consents for same user_id + consent_type + version", %{user: user} do
+ attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ # Insert first consent
+ changeset1 = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ assert {:ok, _consent1} = Repo.insert(changeset1)
+
+ # Insert another consent (allowed - e.g., user re-consents after revoking)
+ changeset2 = UserConsent.grant_changeset(%UserConsent{}, attrs)
+ assert {:ok, _consent2} = Repo.insert(changeset2)
+ end
+
+ test "allows same user to consent to different types", %{user: user} do
+ privacy_attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ terms_attrs = %{
+ user_id: user.id,
+ consent_type: "terms_of_service",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset1 = UserConsent.grant_changeset(%UserConsent{}, privacy_attrs)
+ assert {:ok, _consent1} = Repo.insert(changeset1)
+
+ changeset2 = UserConsent.grant_changeset(%UserConsent{}, terms_attrs)
+ assert {:ok, _consent2} = Repo.insert(changeset2)
+ end
+
+ test "allows same user to consent to different versions", %{user: user} do
+ v1_attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ v2_attrs = %{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "2.0",
+ granted_at: DateTime.utc_now()
+ }
+
+ changeset1 = UserConsent.grant_changeset(%UserConsent{}, v1_attrs)
+ assert {:ok, _consent1} = Repo.insert(changeset1)
+
+ changeset2 = UserConsent.grant_changeset(%UserConsent{}, v2_attrs)
+ assert {:ok, _consent2} = Repo.insert(changeset2)
+ end
+ end
+
+ describe "revoke_changeset/2" do
+ setup do
+ user = user_fixture()
+ granted_at = DateTime.utc_now()
+
+ consent =
+ %UserConsent{}
+ |> UserConsent.grant_changeset(%{
+ user_id: user.id,
+ consent_type: "privacy_policy",
+ version: "1.0",
+ granted_at: granted_at
+ })
+ |> Repo.insert!()
+
+ {:ok, user: user, consent: consent}
+ end
+
+ test "valid attributes create valid changeset", %{consent: consent} do
+ attrs = %{revoked_at: DateTime.utc_now()}
+ changeset = UserConsent.revoke_changeset(consent, attrs)
+ assert changeset.valid?
+ end
+
+ test "requires revoked_at", %{consent: consent} do
+ changeset = UserConsent.revoke_changeset(consent, %{})
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).revoked_at
+ end
+
+ test "prevents revoking already revoked consent", %{consent: consent} do
+ # Revoke once
+ revoked_at = DateTime.utc_now()
+
+ {:ok, revoked_consent} =
+ consent
+ |> UserConsent.revoke_changeset(%{revoked_at: revoked_at})
+ |> Repo.update()
+
+ # Try to revoke again
+ changeset = UserConsent.revoke_changeset(revoked_consent, %{revoked_at: DateTime.utc_now()})
+ refute changeset.valid?
+ assert "consent already revoked" in errors_on(changeset).revoked_at
+ end
+
+ test "successfully revokes consent", %{consent: consent} do
+ revoked_at = DateTime.truncate(DateTime.utc_now(), :second)
+
+ changeset = UserConsent.revoke_changeset(consent, %{revoked_at: revoked_at})
+ assert {:ok, updated_consent} = Repo.update(changeset)
+ assert updated_consent.revoked_at == revoked_at
+ end
+ end
+end
diff --git a/test/towerops/workers/agent_latency_evaluator_test.exs b/test/towerops/workers/agent_latency_evaluator_test.exs
index 072a0698..999ed3f7 100644
--- a/test/towerops/workers/agent_latency_evaluator_test.exs
+++ b/test/towerops/workers/agent_latency_evaluator_test.exs
@@ -1,5 +1,6 @@
defmodule Towerops.Workers.AgentLatencyEvaluatorTest do
use Towerops.DataCase, async: false
+ use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
@@ -10,6 +11,10 @@ defmodule Towerops.Workers.AgentLatencyEvaluatorTest do
alias Towerops.Workers.AgentLatencyEvaluator
describe "perform/1" do
+ test "returns :ok when no reassignment candidates exist" do
+ assert :ok = perform_job(AgentLatencyEvaluator, %{})
+ end
+
test "reassigns device to faster agent when 20%+ improvement available" do
user = user_fixture()
org = organization_fixture(user.id)
@@ -440,5 +445,153 @@ defmodule Towerops.Workers.AgentLatencyEvaluatorTest do
assert updated_site1.agent_token_id == fast_agent.id
assert updated_site2.agent_token_id == fast_agent.id
end
+
+ test "broadcasts PubSub event when device is reassigned" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks showing significant improvement
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Subscribe to PubSub
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:latency")
+
+ # Trigger evaluation
+ assert :ok = perform_job(AgentLatencyEvaluator, %{})
+
+ # Verify PubSub broadcast was sent
+ assert_received {:device_reassigned, candidate}
+ assert candidate.device_id == device.id
+ assert candidate.best_agent_token_id == fast_agent.id
+ assert candidate.current_agent_token_id == slow_agent.id
+ assert candidate.improvement_percent >= 20
+ end
+
+ test "does not broadcast when no reassignment occurs" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: agent1.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks with only one agent (no alternative)
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent1.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Subscribe to PubSub
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:latency")
+
+ # Trigger evaluation
+ assert :ok = perform_job(AgentLatencyEvaluator, %{})
+
+ # Verify no broadcast was sent
+ refute_received {:device_reassigned, _}
+ end
+
+ test "handles reassignment failures gracefully" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks showing improvement
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Delete the site to cause a failure during reassignment
+ Sites.delete_site(site)
+
+ # Worker should complete successfully despite the error
+ assert :ok = perform_job(AgentLatencyEvaluator, %{})
+ end
end
end
diff --git a/test/towerops/workers/stale_agent_worker_test.exs b/test/towerops/workers/stale_agent_worker_test.exs
index 19a4ef92..34f738cb 100644
--- a/test/towerops/workers/stale_agent_worker_test.exs
+++ b/test/towerops/workers/stale_agent_worker_test.exs
@@ -1,5 +1,6 @@
defmodule Towerops.Workers.StaleAgentWorkerTest do
use Towerops.DataCase, async: false
+ use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
@@ -8,6 +9,150 @@ defmodule Towerops.Workers.StaleAgentWorkerTest do
alias Towerops.Agents.AgentToken
alias Towerops.Workers.StaleAgentWorker
+ describe "perform/1" do
+ setup do
+ user = user_fixture()
+ organization = organization_fixture(user.id)
+ {:ok, organization: organization}
+ end
+
+ test "returns :ok when no stale agents exist" do
+ assert :ok = perform_job(StaleAgentWorker, %{})
+ end
+
+ test "returns :ok when all agents are fresh", %{organization: org} do
+ {:ok, agent, _token} = Agents.create_agent_token(org.id, "Fresh Agent")
+
+ # Update heartbeat to now
+ Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{})
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+ end
+
+ test "processes stale agents and broadcasts PubSub event", %{organization: org} do
+ {:ok, agent, _token} = Agents.create_agent_token(org.id, "Stale Agent")
+
+ # Set last_seen_at to 15 minutes ago
+ stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
+
+ Towerops.Repo.update_all(
+ from(a in AgentToken, where: a.id == ^agent.id),
+ set: [last_seen_at: stale_time]
+ )
+
+ # Subscribe to PubSub to verify broadcast
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+
+ # Verify PubSub broadcast was sent
+ assert_received {:agents_stale, stale_agents}
+ assert length(stale_agents) == 1
+ assert hd(stale_agents).id == agent.id
+ end
+
+ test "broadcasts newly stale agents (10-15 minutes)", %{organization: org} do
+ {:ok, agent, _token} = Agents.create_agent_token(org.id, "Newly Stale")
+
+ # Set last_seen_at to 12 minutes ago (within newly stale window)
+ stale_time = DateTime.add(DateTime.utc_now(), -12, :minute)
+
+ Towerops.Repo.update_all(
+ from(a in AgentToken, where: a.id == ^agent.id),
+ set: [last_seen_at: stale_time]
+ )
+
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+
+ assert_received {:agents_stale, stale_agents}
+ assert length(stale_agents) == 1
+ assert hd(stale_agents).id == agent.id
+ assert hd(stale_agents).organization_id == org.id
+ end
+
+ test "broadcasts long-term stale agents (>15 minutes)", %{organization: org} do
+ {:ok, agent, _token} = Agents.create_agent_token(org.id, "Long-term Stale")
+
+ # Set last_seen_at to 30 minutes ago
+ stale_time = DateTime.add(DateTime.utc_now(), -30, :minute)
+
+ Towerops.Repo.update_all(
+ from(a in AgentToken, where: a.id == ^agent.id),
+ set: [last_seen_at: stale_time]
+ )
+
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+
+ assert_received {:agents_stale, stale_agents}
+ assert length(stale_agents) == 1
+ assert hd(stale_agents).id == agent.id
+ end
+
+ test "handles mix of newly stale and long-term stale agents", %{organization: org} do
+ {:ok, newly_stale, _} = Agents.create_agent_token(org.id, "Newly Stale")
+ {:ok, long_term_stale, _} = Agents.create_agent_token(org.id, "Long-term Stale")
+
+ # Newly stale: 12 minutes ago
+ newly_stale_time = DateTime.add(DateTime.utc_now(), -12, :minute)
+ # Long-term stale: 20 minutes ago
+ long_term_time = DateTime.add(DateTime.utc_now(), -20, :minute)
+
+ Towerops.Repo.update_all(
+ from(a in AgentToken, where: a.id == ^newly_stale.id),
+ set: [last_seen_at: newly_stale_time]
+ )
+
+ Towerops.Repo.update_all(
+ from(a in AgentToken, where: a.id == ^long_term_stale.id),
+ set: [last_seen_at: long_term_time]
+ )
+
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+
+ # Verify both agents are included in PubSub broadcast
+ assert_received {:agents_stale, stale_agents}
+ assert length(stale_agents) == 2
+
+ stale_ids = Enum.map(stale_agents, & &1.id)
+ assert newly_stale.id in stale_ids
+ assert long_term_stale.id in stale_ids
+ end
+
+ test "does not broadcast when no stale agents exist" do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+
+ refute_received {:agents_stale, _}
+ end
+
+ test "preloads organization association for stale agents", %{organization: org} do
+ {:ok, agent, _token} = Agents.create_agent_token(org.id, "Stale Agent")
+
+ stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
+
+ Towerops.Repo.update_all(
+ from(a in AgentToken, where: a.id == ^agent.id),
+ set: [last_seen_at: stale_time]
+ )
+
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
+
+ assert :ok = perform_job(StaleAgentWorker, %{})
+
+ assert_received {:agents_stale, [stale_agent]}
+ # Verify organization is preloaded
+ assert %Ecto.Association.NotLoaded{} != stale_agent.organization
+ assert stale_agent.organization.id == org.id
+ end
+ end
+
describe "find_stale_agents/0" do
setup do
user = user_fixture()