test: lift coverage to 87.14% via targeted branch tests

Adds tests across five files:
- Towerops.Monitoring.CheckQuery (full coverage of all composable filter
  fragments via inspect+Repo.all)
- ToweropsWeb.Admin.SecurityLive.Index change_filter event for both
  permanent and temporary filter values
- Towerops.Agents.AgentCache cleanup_expired handle_info path that
  prunes stale device entries while preserving :global_default
- ToweropsWeb.Api.V1.AgentReleaseWebhookController happy path
  (notified/skipped/version) and error path (Codeberg 503)
- ToweropsWeb.DeviceLive.Index reorder_device / reorder_site
  unauthorized branches via cross-org device/site fixtures
This commit is contained in:
Graham McIntire 2026-05-09 11:44:25 -05:00
parent 0c63cb608e
commit 38619f2b88
5 changed files with 234 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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")