diff --git a/test/towerops/agents/agent_cache_test.exs b/test/towerops/agents/agent_cache_test.exs index 9ed17107..5c5adaf3 100644 --- a/test/towerops/agents/agent_cache_test.exs +++ b/test/towerops/agents/agent_cache_test.exs @@ -97,5 +97,31 @@ defmodule Towerops.Agents.AgentCacheTest do AgentCache.refresh_global_default() assert AgentCache.global_default_cloud_poller() == nil end + + test "global_default returns refreshed value when key not in ETS" do + # Delete just the :global_default entry (not the device entries) + _ = :ets.delete(:device_agent_cache, :global_default) + assert AgentCache.global_default_cloud_poller() == nil + end + end + + describe "handle_info :cleanup_expired" do + test "deletes expired device entries while keeping :global_default", %{device: device} do + # Insert an expired device entry directly + now = System.monotonic_time(:millisecond) + _ = :ets.insert(:device_agent_cache, {{:device, device.id}, true, now - 1000}) + + # Make sure global_default is set so we can prove cleanup preserves it + AgentCache.refresh_global_default() + + send(AgentCache, :cleanup_expired) + # Give the GenServer a moment to process + _ = :sys.get_state(AgentCache) + + # Expired device entry removed + assert :ets.lookup(:device_agent_cache, {:device, device.id}) == [] + # Global default key preserved + assert [{:global_default, _}] = :ets.lookup(:device_agent_cache, :global_default) + end end end diff --git a/test/towerops/monitoring/check_query_test.exs b/test/towerops/monitoring/check_query_test.exs new file mode 100644 index 00000000..5349cd6e --- /dev/null +++ b/test/towerops/monitoring/check_query_test.exs @@ -0,0 +1,67 @@ +defmodule Towerops.Monitoring.CheckQueryTest do + use Towerops.DataCase, async: true + + import Ecto.Query + + alias Towerops.Monitoring.Check + alias Towerops.Monitoring.CheckQuery + + describe "query composition" do + test "base/0 returns the Check module" do + assert CheckQuery.base() == Check + end + + test "for_organization/2 adds organization filter" do + org_id = Ecto.UUID.generate() + query = CheckQuery.for_organization(org_id) + assert %Ecto.Query{} = query + assert inspect(query) =~ "organization_id" + end + + test "for_device/2 adds device filter" do + device_id = Ecto.UUID.generate() + query = CheckQuery.for_device(device_id) + assert %Ecto.Query{} = query + assert inspect(query) =~ "device_id" + end + + test "of_type/2 adds check_type filter" do + query = CheckQuery.of_type("ping") + assert %Ecto.Query{} = query + assert inspect(query) =~ "check_type" + end + + test "with_enabled/2 adds enabled filter" do + query = CheckQuery.with_enabled(true) + assert %Ecto.Query{} = query + assert inspect(query) =~ "enabled" + end + + test "order_by_name/1 orders ascending by name" do + query = CheckQuery.order_by_name() + assert %Ecto.Query{} = query + assert inspect(query) =~ "order_by" + end + + test "fragments compose into a chained query" do + org_id = Ecto.UUID.generate() + device_id = Ecto.UUID.generate() + + composed = + CheckQuery.base() + |> CheckQuery.for_organization(org_id) + |> CheckQuery.for_device(device_id) + |> CheckQuery.of_type("ping") + |> CheckQuery.with_enabled(true) + |> CheckQuery.order_by_name() + + assert %Ecto.Query{} = composed + # All clauses applied + assert length(composed.wheres) == 4 + end + + test "queries execute against the empty repo" do + assert [] = CheckQuery.base() |> where([c], false) |> Repo.all() + end + end +end diff --git a/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs b/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs index 09a36ba8..6a3fdb7d 100644 --- a/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs +++ b/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs @@ -1,6 +1,8 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookControllerTest do use ToweropsWeb.ConnCase, async: true + alias Towerops.Agents.ReleaseChecker + setup do conn = build_conn() @@ -41,6 +43,39 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookControllerTest do response = json_response(conn, 502) assert response["status"] == "error" end + + test "returns 200 ok with notified/skipped/version on success", %{conn: conn} do + # Stub ReleaseChecker so broadcast_mass_update returns {:ok, ...} + Req.Test.stub(ReleaseChecker, fn r_conn -> + Req.Test.json(r_conn, %{ + "tag_name" => "v9.9.9", + "assets" => [] + }) + end) + + ReleaseChecker.invalidate_cache() + + conn = post(conn, ~p"/api/v1/webhooks/agent-release") + + response = json_response(conn, 200) + assert response["status"] == "ok" + assert response["version"] == "9.9.9" + assert is_integer(response["notified"]) + assert is_integer(response["skipped"]) + end + + test "returns 502 when broadcast_mass_update returns error", %{conn: conn} do + # Stub the underlying API call to fail with a non-200 status + Req.Test.stub(ReleaseChecker, fn r_conn -> + r_conn |> Plug.Conn.put_status(503) |> Req.Test.json(%{}) + end) + + ReleaseChecker.invalidate_cache() + + conn = post(conn, ~p"/api/v1/webhooks/agent-release") + response = json_response(conn, 502) + assert response["status"] == "error" + end end describe "check_timestamp/1" do diff --git a/test/towerops_web/live/admin/security_live/index_test.exs b/test/towerops_web/live/admin/security_live/index_test.exs index 9903cb1b..4a4a6269 100644 --- a/test/towerops_web/live/admin/security_live/index_test.exs +++ b/test/towerops_web/live/admin/security_live/index_test.exs @@ -169,6 +169,34 @@ defmodule ToweropsWeb.Admin.SecurityLive.IndexTest do end end + describe "change_filter event" do + test "permanent filter switches to ?filter=permanent", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/security?tab=blocked") + + _ = render_hook(view, "change_filter", %{"filter" => "permanent"}) + assert_patch(view, ~p"/admin/security?tab=blocked&filter=permanent") + end + + test "temporary filter switches to ?filter=temporary", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/security?tab=blocked") + + _ = render_hook(view, "change_filter", %{"filter" => "temporary"}) + assert_patch(view, ~p"/admin/security?tab=blocked&filter=temporary") + end + end + + describe "filter param drives load_data branches" do + test "permanent filter renders blocked-IP page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/security?tab=blocked&filter=permanent") + assert html =~ "Denied IP Addresses" + end + + test "temporary filter renders blocked-IP page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/security?tab=blocked&filter=temporary") + assert html =~ "Denied IP Addresses" + end + end + describe "PubSub updates" do test "refreshes whitelist data on PubSub broadcast", %{conn: conn, user: user} do {:ok, view, _html} = live(conn, ~p"/admin/security") diff --git a/test/towerops_web/live/device_live/index_test.exs b/test/towerops_web/live/device_live/index_test.exs index b3cc25cd..1603dde4 100644 --- a/test/towerops_web/live/device_live/index_test.exs +++ b/test/towerops_web/live/device_live/index_test.exs @@ -550,6 +550,84 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do end end + describe "reorder_device / reorder_site unauthorized branch" do + test "reorder_device flashes 'no access' for cross-org device", %{ + conn: conn, + site: site, + organization: organization, + user: user + } do + _ = user + # Need at least one device on this org so the device-list element renders + {:ok, _own_device} = + Devices.create_device(%{ + name: "OwnDevice", + ip_address: "10.0.0.1", + site_id: site.id, + organization_id: organization.id + }) + + # Cross-org device that exists but belongs elsewhere + cross_user = Towerops.AccountsFixtures.user_fixture() + cross_org = Towerops.OrganizationsFixtures.organization_fixture(cross_user.id) + + {:ok, cross_site} = + Towerops.Sites.create_site(%{name: "Cross", organization_id: cross_org.id}) + + {:ok, cross_device} = + Devices.create_device(%{ + name: "Cross", + ip_address: "10.99.0.1", + site_id: cross_site.id, + organization_id: cross_org.id + }) + + {:ok, view, _html} = live(conn, ~p"/devices") + + html = + view + |> element("#device-list") + |> render_hook("reorder_device", %{ + "device_id" => cross_device.id, + "new_position" => "1" + }) + + assert html =~ "don't have access" or html =~ "don't have access" + end + + test "reorder_site flashes 'no access' for cross-org site", %{ + conn: conn, + site: site, + organization: organization + } do + {:ok, _own_device} = + Devices.create_device(%{ + name: "OwnDevice2", + ip_address: "10.0.0.2", + site_id: site.id, + organization_id: organization.id + }) + + cross_user = Towerops.AccountsFixtures.user_fixture() + cross_org = Towerops.OrganizationsFixtures.organization_fixture(cross_user.id) + + {:ok, cross_site} = + Towerops.Sites.create_site(%{name: "Cross", organization_id: cross_org.id}) + + {:ok, view, _html} = live(conn, ~p"/devices") + + html = + view + |> element("#device-list") + |> render_hook("reorder_site", %{ + "site_id" => cross_site.id, + "new_position" => "1" + }) + + assert html =~ "don't have access" or html =~ "don't have access" + end + end + describe "handle_info catch-all" do test "ignores unrelated messages", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/devices")