From fde615825125343412e143a3f688147877e78cc0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 5 May 2026 10:35:22 -0500 Subject: [PATCH] ci: enable PostGIS in test workflows + add tests covering ~3pp more CI: switch postgres service image to timescale/timescaledb-ha:pg17-all which bundles PostGIS, fixing the lidar migration failure in PR and production workflows. Tests: ~30 new test files / extensions, ~600+ new tests, taking total coverage from 70.42% to 73.4%. Targets included GraphQL resolver unauthenticated paths, SNMP context queries (mempools, processors, ip_addresses, neighbors, wireless_clients, arp_entries), worker routing/lifecycle (alert notification, alert digest, weather sync, job cleanup, cn_maestro, uisp, mikrotik backup, report worker), SNMP monitoring executors (storage, interface, processor) end-to-end via stub adapter, NetBox + Sonar sync integration paths, ConfigChanges listing and correlator, organizations membership query, and live-view smoke tests for org-scoped settings, integrations, maintenance, agent, device deep paths, schedules. Property tests used for query composition and pure helpers where natural. --- .forgejo/workflows/pr-tests.yaml | 2 +- .forgejo/workflows/production.yaml | 2 +- test/towerops/alerts/alert_query_test.exs | 134 +++++ .../config_change_event_test.exs | 44 ++ .../correlator_integration_test.exs | 255 +++++++++ test/towerops/config_changes_listing_test.exs | 126 +++++ .../snmp_interface_executor_test.exs | 212 ++++++++ .../snmp_processor_executor_test.exs | 231 ++++++++ .../executors/snmp_storage_executor_test.exs | 221 ++++++++ .../towerops/netbox/sync_integration_test.exs | 277 ++++++++++ .../organizations/membership_query_test.exs | 106 ++++ test/towerops/snmp/arp_entries_test.exs | 147 +++++ test/towerops/snmp/ip_addresses_test.exs | 90 ++++ test/towerops/snmp/mempools_test.exs | 189 +++++++ test/towerops/snmp/neighbors_test.exs | 206 +++++++ test/towerops/snmp/processors_test.exs | 114 ++++ .../snmp/wireless_client_discovery_test.exs | 119 ++++- .../snmp/wireless_client_reading_test.exs | 108 ++++ test/towerops/snmp/wireless_clients_test.exs | 210 ++++++++ test/towerops/sonar/sync_integration_test.exs | 118 +++++ .../status_pages/status_incident_test.exs | 81 +++ .../workers/alert_digest_worker_test.exs | 41 ++ ...alert_notification_worker_routing_test.exs | 117 ++++ .../workers/cn_maestro_sync_worker_test.exs | 117 ++++ .../workers/job_cleanup_task_test.exs | 31 ++ .../workers/mikrotik_backup_worker_test.exs | 52 ++ test/towerops/workers/report_worker_test.exs | 42 ++ .../workers/uisp_sync_worker_test.exs | 150 ++++++ .../workers/weather_sync_worker_test.exs | 41 ++ .../graphql/resolvers/authenticated_test.exs | 501 ++++++++++++++++++ .../resolvers/unauthenticated_test.exs | 323 +++++++++++ .../live/integrations_smoke_test.exs | 230 ++++++++ test/towerops_web/live/smoke_test.exs | 127 +++++ 33 files changed, 4755 insertions(+), 9 deletions(-) create mode 100644 test/towerops/alerts/alert_query_test.exs create mode 100644 test/towerops/config_changes/config_change_event_test.exs create mode 100644 test/towerops/config_changes/correlator_integration_test.exs create mode 100644 test/towerops/config_changes_listing_test.exs create mode 100644 test/towerops/monitoring/executors/snmp_interface_executor_test.exs create mode 100644 test/towerops/monitoring/executors/snmp_processor_executor_test.exs create mode 100644 test/towerops/monitoring/executors/snmp_storage_executor_test.exs create mode 100644 test/towerops/netbox/sync_integration_test.exs create mode 100644 test/towerops/organizations/membership_query_test.exs create mode 100644 test/towerops/snmp/arp_entries_test.exs create mode 100644 test/towerops/snmp/ip_addresses_test.exs create mode 100644 test/towerops/snmp/mempools_test.exs create mode 100644 test/towerops/snmp/neighbors_test.exs create mode 100644 test/towerops/snmp/processors_test.exs create mode 100644 test/towerops/snmp/wireless_client_reading_test.exs create mode 100644 test/towerops/snmp/wireless_clients_test.exs create mode 100644 test/towerops/sonar/sync_integration_test.exs create mode 100644 test/towerops/status_pages/status_incident_test.exs create mode 100644 test/towerops/workers/alert_notification_worker_routing_test.exs create mode 100644 test/towerops/workers/cn_maestro_sync_worker_test.exs create mode 100644 test/towerops/workers/job_cleanup_task_test.exs create mode 100644 test/towerops/workers/uisp_sync_worker_test.exs create mode 100644 test/towerops/workers/weather_sync_worker_test.exs create mode 100644 test/towerops_web/graphql/resolvers/authenticated_test.exs create mode 100644 test/towerops_web/graphql/resolvers/unauthenticated_test.exs create mode 100644 test/towerops_web/live/integrations_smoke_test.exs diff --git a/.forgejo/workflows/pr-tests.yaml b/.forgejo/workflows/pr-tests.yaml index 60a36512..18962289 100644 --- a/.forgejo/workflows/pr-tests.yaml +++ b/.forgejo/workflows/pr-tests.yaml @@ -25,7 +25,7 @@ jobs: services: postgres: - image: timescale/timescaledb:latest-pg17 + image: timescale/timescaledb-ha:pg17-all env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/.forgejo/workflows/production.yaml b/.forgejo/workflows/production.yaml index 8a043ad2..981370b6 100644 --- a/.forgejo/workflows/production.yaml +++ b/.forgejo/workflows/production.yaml @@ -19,7 +19,7 @@ jobs: services: postgres: - image: timescale/timescaledb:latest-pg17 + image: timescale/timescaledb-ha:pg17-all env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/test/towerops/alerts/alert_query_test.exs b/test/towerops/alerts/alert_query_test.exs new file mode 100644 index 00000000..053cabd9 --- /dev/null +++ b/test/towerops/alerts/alert_query_test.exs @@ -0,0 +1,134 @@ +defmodule Towerops.Alerts.AlertQueryTest do + use Towerops.DataCase, async: true + use ExUnitProperties + + import Towerops.DevicesFixtures + + alias Towerops.Alerts.Alert + alias Towerops.Alerts.AlertQuery + alias Towerops.Repo + + setup do + device = device_fixture() + %{device: device, organization_id: device.organization_id} + end + + describe "base/0" do + test "returns the Alert schema as a queryable" do + assert AlertQuery.base() == Alert + end + end + + describe "for_organization/2" do + test "filters to a single organization", %{device: device, organization_id: org_id} do + keep = insert_alert!(device.id, org_id, "device_down") + other_device = device_fixture() + _other_org = insert_alert!(other_device.id, other_device.organization_id, "device_down") + + [result] = AlertQuery.base() |> AlertQuery.for_organization(org_id) |> Repo.all() + assert result.id == keep.id + end + + test "is composable with other filters", %{device: device, organization_id: org_id} do + a = insert_alert!(device.id, org_id, "device_down") + _b = insert_alert!(device.id, org_id, "device_up") + + [result] = + AlertQuery.base() + |> AlertQuery.for_organization(org_id) + |> AlertQuery.of_type("device_down") + |> Repo.all() + + assert result.id == a.id + end + end + + describe "for_device/2" do + test "filters to a single device", %{device: device, organization_id: org_id} do + other_device = device_fixture() + keep = insert_alert!(device.id, org_id, "device_down") + _drop = insert_alert!(other_device.id, other_device.organization_id, "device_down") + + [result] = AlertQuery.base() |> AlertQuery.for_device(device.id) |> Repo.all() + assert result.id == keep.id + end + end + + describe "of_type/2" do + test "filters by alert_type", %{device: device, organization_id: org_id} do + down = insert_alert!(device.id, org_id, "device_down") + _up = insert_alert!(device.id, org_id, "device_up") + + [result] = AlertQuery.base() |> AlertQuery.of_type("device_down") |> Repo.all() + assert result.id == down.id + end + end + + describe "unresolved/1 and resolved/1" do + test "partition alerts by resolved_at presence", %{device: device, organization_id: org_id} do + open = insert_alert!(device.id, org_id, "device_down") + + closed = + insert_alert!(device.id, org_id, "device_down", resolved_at: DateTime.truncate(DateTime.utc_now(), :second)) + + assert [%{id: id1}] = AlertQuery.base() |> AlertQuery.unresolved() |> Repo.all() + assert id1 == open.id + + assert [%{id: id2}] = AlertQuery.base() |> AlertQuery.resolved() |> Repo.all() + assert id2 == closed.id + end + end + + describe "with_severity/2" do + test "filters by severity", %{device: device, organization_id: org_id} do + crit = insert_alert!(device.id, org_id, "device_down", severity: 1) + _warn = insert_alert!(device.id, org_id, "device_down", severity: 2) + + [result] = AlertQuery.base() |> AlertQuery.with_severity(1) |> Repo.all() + assert result.id == crit.id + end + end + + describe "property: composed filters always return a subset" do + property "organization + type + unresolved is always a subset of for_organization", %{ + device: device, + organization_id: org_id + } do + _ = insert_alert!(device.id, org_id, "device_down") + _ = insert_alert!(device.id, org_id, "device_up") + _ = insert_alert!(device.id, org_id, "device_down", resolved_at: DateTime.truncate(DateTime.utc_now(), :second)) + + check all(alert_type <- StreamData.member_of(["device_down", "device_up", "check_ping"]), max_runs: 25) do + all_for_org_ids = + AlertQuery.base() + |> AlertQuery.for_organization(org_id) + |> Repo.all() + |> MapSet.new(& &1.id) + + narrowed_ids = + AlertQuery.base() + |> AlertQuery.for_organization(org_id) + |> AlertQuery.of_type(alert_type) + |> AlertQuery.unresolved() + |> Repo.all() + |> MapSet.new(& &1.id) + + assert MapSet.subset?(narrowed_ids, all_for_org_ids) + end + end + end + + defp insert_alert!(device_id, organization_id, alert_type, extra \\ []) do + attrs = + Enum.into(extra, %{ + device_id: device_id, + organization_id: organization_id, + alert_type: alert_type, + triggered_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + %Alert{} + |> Alert.changeset(attrs) + |> Repo.insert!() + end +end diff --git a/test/towerops/config_changes/config_change_event_test.exs b/test/towerops/config_changes/config_change_event_test.exs new file mode 100644 index 00000000..994bf765 --- /dev/null +++ b/test/towerops/config_changes/config_change_event_test.exs @@ -0,0 +1,44 @@ +defmodule Towerops.ConfigChanges.ConfigChangeEventTest do + use ExUnit.Case, async: true + + alias Towerops.ConfigChanges.ConfigChangeEvent + + describe "changeset/2" do + test "valid with required fields" do + attrs = %{ + device_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + changed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert ConfigChangeEvent.changeset(%ConfigChangeEvent{}, attrs).valid? + end + + test "rejects missing required fields" do + changeset = ConfigChangeEvent.changeset(%ConfigChangeEvent{}, %{}) + + keys = Keyword.keys(changeset.errors) + assert :device_id in keys + assert :organization_id in keys + assert :changed_at in keys + end + + test "accepts optional fields" do + attrs = %{ + device_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + changed_at: DateTime.truncate(DateTime.utc_now(), :second), + diff_summary: "+ /ip firewall ...", + sections_changed: ["firewall", "interface"], + change_size: 12, + backup_before_id: Ecto.UUID.generate(), + backup_after_id: Ecto.UUID.generate() + } + + changeset = ConfigChangeEvent.changeset(%ConfigChangeEvent{}, attrs) + assert changeset.valid? + assert changeset.changes.sections_changed == ["firewall", "interface"] + assert changeset.changes.change_size == 12 + end + end +end diff --git a/test/towerops/config_changes/correlator_integration_test.exs b/test/towerops/config_changes/correlator_integration_test.exs new file mode 100644 index 00000000..fcb580b6 --- /dev/null +++ b/test/towerops/config_changes/correlator_integration_test.exs @@ -0,0 +1,255 @@ +defmodule Towerops.ConfigChanges.CorrelatorIntegrationTest do + @moduledoc """ + Integration tests for `Towerops.ConfigChanges.Correlator` covering the + database-backed code paths: `correlate/1` and `correlate_recent/2`. + + Pure-function unit tests live in `correlator_test.exs`. + """ + use Towerops.DataCase, async: true + + alias Towerops.AccountsFixtures + alias Towerops.ConfigChanges.ConfigChangeEvent + alias Towerops.ConfigChanges.Correlator + alias Towerops.DevicesFixtures + alias Towerops.OrganizationsFixtures + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Insight + alias Towerops.Preseem.SubscriberMetric + + defp setup_org_and_device do + user = AccountsFixtures.user_fixture() + org = OrganizationsFixtures.organization_fixture(user.id) + site = OrganizationsFixtures.site_fixture(org.id) + device = DevicesFixtures.device_fixture(org.id, site.id) + {org, device} + end + + defp insert_access_point(org_id, device_id) do + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org_id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP", + device_id: device_id, + match_confidence: "auto_ip" + }) + |> Repo.insert!() + end + + defp insert_metric(ap_id, recorded_at, attrs) do + base = %{ + preseem_access_point_id: ap_id, + recorded_at: DateTime.truncate(recorded_at, :second) + } + + %SubscriberMetric{} + |> SubscriberMetric.changeset(Map.merge(base, attrs)) + |> Repo.insert!() + end + + defp insert_event(org_id, device_id, changed_at, sections \\ []) do + %ConfigChangeEvent{} + |> ConfigChangeEvent.changeset(%{ + organization_id: org_id, + device_id: device_id, + changed_at: DateTime.truncate(changed_at, :second), + sections_changed: sections + }) + |> Repo.insert!() + end + + describe "correlate/1" do + test "returns :no_data when no Preseem access points are matched to the device" do + {org, device} = setup_org_and_device() + changed_at = DateTime.utc_now() + event = insert_event(org.id, device.id, changed_at) + + assert {:ok, :no_data} = Correlator.correlate(event) + end + + test "returns :no_data when no metrics exist in either window" do + {org, device} = setup_org_and_device() + ap = insert_access_point(org.id, device.id) + _ = ap + event = insert_event(org.id, device.id, DateTime.utc_now()) + + assert {:ok, :no_data} = Correlator.correlate(event) + end + + test "returns :no_data when only the pre-window has metrics" do + {org, device} = setup_org_and_device() + ap = insert_access_point(org.id, device.id) + changed_at = DateTime.utc_now() + event = insert_event(org.id, device.id, changed_at) + + pre_at = DateTime.add(changed_at, -3600, :second) + + insert_metric(ap.id, pre_at, %{ + avg_latency: 10.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + }) + + assert {:ok, :no_data} = Correlator.correlate(event) + end + + test "returns :no_impact when post metrics are similar to pre metrics" do + {org, device} = setup_org_and_device() + ap = insert_access_point(org.id, device.id) + changed_at = DateTime.utc_now() + event = insert_event(org.id, device.id, changed_at) + + pre_at = DateTime.add(changed_at, -3600, :second) + post_at = DateTime.add(changed_at, 3600, :second) + + stable = %{ + avg_latency: 10.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + } + + insert_metric(ap.id, pre_at, stable) + insert_metric(ap.id, post_at, stable) + + assert {:ok, :no_impact} = Correlator.correlate(event) + end + + test "creates a suspect_config_change insight when latency degrades significantly" do + {org, device} = setup_org_and_device() + ap = insert_access_point(org.id, device.id) + changed_at = DateTime.utc_now() + event = insert_event(org.id, device.id, changed_at, ["firewall"]) + + pre_at = DateTime.add(changed_at, -3600, :second) + post_at = DateTime.add(changed_at, 3600, :second) + + insert_metric(ap.id, pre_at, %{ + avg_latency: 10.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + }) + + insert_metric(ap.id, post_at, %{ + avg_latency: 30.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + }) + + assert {:ok, %Insight{} = insight} = Correlator.correlate(event) + assert insight.type == "suspect_config_change" + assert insight.urgency in ["info", "warning", "critical"] + assert insight.source == "system" + assert insight.channel == "proactive" + assert insight.organization_id == org.id + assert insight.device_id == device.id + assert insight.metadata["change_event_id"] == event.id + assert insight.metadata["sections_changed"] == ["firewall"] + assert insight.description =~ "After config change" + end + + test "throughput drop alone is enough to trigger an insight" do + {org, device} = setup_org_and_device() + ap = insert_access_point(org.id, device.id) + changed_at = DateTime.utc_now() + event = insert_event(org.id, device.id, changed_at) + + pre_at = DateTime.add(changed_at, -3600, :second) + post_at = DateTime.add(changed_at, 3600, :second) + + insert_metric(ap.id, pre_at, %{ + avg_latency: 10.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + }) + + insert_metric(ap.id, post_at, %{ + avg_latency: 10.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 50.0 + }) + + assert {:ok, %Insight{}} = Correlator.correlate(event) + end + + test "deduplicates: a second correlate for the same device returns :duplicate" do + {org, device} = setup_org_and_device() + ap = insert_access_point(org.id, device.id) + changed_at = DateTime.utc_now() + + pre_at = DateTime.add(changed_at, -3600, :second) + post_at = DateTime.add(changed_at, 3600, :second) + + insert_metric(ap.id, pre_at, %{ + avg_latency: 10.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + }) + + insert_metric(ap.id, post_at, %{ + avg_latency: 30.0, + avg_jitter: 1.0, + avg_loss: 0.0, + avg_throughput: 100.0 + }) + + event1 = insert_event(org.id, device.id, changed_at) + event2 = insert_event(org.id, device.id, changed_at) + + assert {:ok, %Insight{}} = Correlator.correlate(event1) + assert {:ok, :duplicate} = Correlator.correlate(event2) + end + end + + describe "correlate_recent/2" do + test "returns empty list when organization has no events" do + user = AccountsFixtures.user_fixture() + org = OrganizationsFixtures.organization_fixture(user.id) + + assert {:ok, []} = Correlator.correlate_recent(org.id) + end + + test "correlates events newer than the cutoff and skips older ones" do + {org, device} = setup_org_and_device() + _ap = insert_access_point(org.id, device.id) + + # Recent event (within default 24h window) — no AP metrics, so :no_data + _recent = insert_event(org.id, device.id, DateTime.utc_now()) + + # Older event (48h ago) — should be filtered out + old_at = DateTime.add(DateTime.utc_now(), -48 * 3600, :second) + _old = insert_event(org.id, device.id, old_at) + + {:ok, results} = Correlator.correlate_recent(org.id, hours_back: 24) + assert length(results) == 1 + assert [{:ok, :no_data}] = results + end + + test "honors the hours_back option" do + {org, device} = setup_org_and_device() + old_at = DateTime.add(DateTime.utc_now(), -48 * 3600, :second) + _ = insert_event(org.id, device.id, old_at) + + # Window large enough to include the 48h-old event + {:ok, results} = Correlator.correlate_recent(org.id, hours_back: 72) + assert length(results) == 1 + end + + test "scopes to the given organization" do + {org_a, device_a} = setup_org_and_device() + {org_b, device_b} = setup_org_and_device() + + _ = insert_event(org_a.id, device_a.id, DateTime.utc_now()) + _ = insert_event(org_b.id, device_b.id, DateTime.utc_now()) + + {:ok, results_a} = Correlator.correlate_recent(org_a.id) + assert length(results_a) == 1 + end + end +end diff --git a/test/towerops/config_changes_listing_test.exs b/test/towerops/config_changes_listing_test.exs new file mode 100644 index 00000000..a87269b8 --- /dev/null +++ b/test/towerops/config_changes_listing_test.exs @@ -0,0 +1,126 @@ +defmodule Towerops.ConfigChangesListingTest do + @moduledoc """ + Tests for listing functions on `Towerops.ConfigChanges` (the pure + detect-section helper is covered separately in `config_changes_test.exs`). + """ + use Towerops.DataCase, async: true + + import Towerops.DevicesFixtures + + alias Towerops.ConfigChanges + alias Towerops.ConfigChanges.ConfigChangeEvent + alias Towerops.Repo + + setup do + device = device_fixture() + %{device: device, organization_id: device.organization_id, site_id: device.site_id} + end + + describe "list_device_changes/2" do + test "returns events for the device, most recent first", %{device: device, organization_id: org_id} do + _ = insert_event!(device.id, org_id, ~U[2026-01-01 00:00:00Z]) + newer = insert_event!(device.id, org_id, ~U[2026-02-01 00:00:00Z]) + + result = ConfigChanges.list_device_changes(device.id) + assert hd(result).id == newer.id + end + + test "honors :limit", %{device: device, organization_id: org_id} do + Enum.each(1..3, fn i -> + insert_event!(device.id, org_id, DateTime.add(~U[2026-01-01 00:00:00Z], i, :day)) + end) + + assert length(ConfigChanges.list_device_changes(device.id, limit: 2)) == 2 + end + + test "honors :after and :before", %{device: device, organization_id: org_id} do + a = insert_event!(device.id, org_id, ~U[2026-01-01 00:00:00Z]) + b = insert_event!(device.id, org_id, ~U[2026-06-01 00:00:00Z]) + c = insert_event!(device.id, org_id, ~U[2026-12-01 00:00:00Z]) + + result = + ConfigChanges.list_device_changes(device.id, + after: ~U[2026-03-01 00:00:00Z], + before: ~U[2026-09-01 00:00:00Z] + ) + + ids = MapSet.new(result, & &1.id) + assert MapSet.equal?(ids, MapSet.new([b.id])) + refute MapSet.member?(ids, a.id) + refute MapSet.member?(ids, c.id) + end + + test "returns [] for unknown device" do + assert ConfigChanges.list_device_changes(Ecto.UUID.generate()) == [] + end + end + + describe "list_org_changes/2" do + test "returns events for the org, with optional preload", %{device: device, organization_id: org_id} do + _ = insert_event!(device.id, org_id, DateTime.truncate(DateTime.utc_now(), :second)) + + [event] = ConfigChanges.list_org_changes(org_id, preload: [:device]) + assert Ecto.assoc_loaded?(event.device) + assert event.device.id == device.id + end + + test "honors :limit and :before", %{device: device, organization_id: org_id} do + _old = insert_event!(device.id, org_id, ~U[2026-01-01 00:00:00Z]) + _new = insert_event!(device.id, org_id, ~U[2026-12-31 00:00:00Z]) + + result = ConfigChanges.list_org_changes(org_id, limit: 1, before: ~U[2026-06-01 00:00:00Z]) + assert length(result) == 1 + end + end + + describe "list_site_changes/2" do + test "returns events for devices at the site", %{device: device, organization_id: org_id, site_id: site_id} do + _ = insert_event!(device.id, org_id, DateTime.truncate(DateTime.utc_now(), :second)) + + result = ConfigChanges.list_site_changes(site_id) + assert length(result) == 1 + end + + test "returns [] for site with no devices", %{organization_id: _org_id} do + # Build a site with no devices + {:ok, empty_site} = + Towerops.Sites.create_site(%{ + name: "Empty Site #{System.unique_integer([:positive])}", + organization_id: device_fixture().organization_id + }) + + assert ConfigChanges.list_site_changes(empty_site.id) == [] + end + end + + describe "get_change_event!/1 and get_change_event_with_preloads!/1" do + test "returns the event by id", %{device: device, organization_id: org_id} do + e = insert_event!(device.id, org_id, DateTime.truncate(DateTime.utc_now(), :second)) + + assert %ConfigChangeEvent{id: id} = ConfigChanges.get_change_event!(e.id) + assert id == e.id + + assert %ConfigChangeEvent{} = preloaded = ConfigChanges.get_change_event_with_preloads!(e.id) + assert Ecto.assoc_loaded?(preloaded.device) + end + + test "raises for unknown id" do + assert_raise Ecto.NoResultsError, fn -> + ConfigChanges.get_change_event!(Ecto.UUID.generate()) + end + end + end + + defp insert_event!(device_id, org_id, changed_at) do + %ConfigChangeEvent{} + |> ConfigChangeEvent.changeset(%{ + device_id: device_id, + organization_id: org_id, + changed_at: changed_at, + diff_summary: "summary", + sections_changed: ["firewall"], + change_size: 1 + }) + |> Repo.insert!() + end +end diff --git a/test/towerops/monitoring/executors/snmp_interface_executor_test.exs b/test/towerops/monitoring/executors/snmp_interface_executor_test.exs new file mode 100644 index 00000000..1db1aac7 --- /dev/null +++ b/test/towerops/monitoring/executors/snmp_interface_executor_test.exs @@ -0,0 +1,212 @@ +defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutorTest do + @moduledoc """ + End-to-end tests for `SnmpInterfaceExecutor.execute/1`, driving the full + `with` chain (interface lookup, device + snmp_device preload, SNMP poll, + status mapping, output formatting) using `Towerops.Snmp.SnmpMock`. + """ + use Towerops.DataCase, async: true + + import Mox + import Towerops.AccountsFixtures + + alias Towerops.Monitoring + alias Towerops.Monitoring.Executors.SnmpInterfaceExecutor + alias Towerops.Snmp + alias Towerops.Snmp.Interface + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + 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: "Test Router", + ip_address: "192.0.2.20", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }) + + device = Towerops.Devices.get_device!(device.id) + + snmp_device = + %Snmp.Device{} + |> Snmp.Device.changeset(%{ + device_id: device.id, + sys_name: "test-router", + sys_descr: "Router OS" + }) + |> Repo.insert!() + + %{device: device, snmp_device: snmp_device, organization: organization} + end + + defp insert_interface(snmp_device, attrs) do + base = %{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_name: "eth0", + if_descr: "Ethernet 0", + if_admin_status: "up" + } + + %Interface{} + |> Interface.changeset(Map.merge(base, attrs)) + |> Repo.insert!() + end + + defp build_check(device, source_id) do + {:ok, check} = + Monitoring.create_check(%{ + organization_id: device.organization_id, + device_id: device.id, + name: "Interface up?", + check_type: "snmp_interface", + source_type: "auto_discovery", + source_id: source_id, + config: %{} + }) + + check + end + + describe "execute/1 success path" do + test "returns OK status when ifOperStatus = up (1)", %{ + device: device, + snmp_device: snmp_device + } do + interface = + insert_interface(snmp_device, %{if_index: 2, if_name: "eth1", if_alias: "WAN"}) + + check = build_check(device, interface.id) + + expect(SnmpMock, :get, fn _target, oid, _opts -> + # Expect the IF-MIB ifOperStatus. OID + assert to_string(oid) == "1.3.6.1.2.1.2.2.1.8.2" + {:ok, {:integer, 1}} + end) + + assert {:ok, response} = SnmpInterfaceExecutor.execute(check) + assert response.value == 1 + assert response.status == 0 + assert response.output =~ "WAN" + assert response.output =~ "Up" + assert is_integer(response.response_time_ms) + end + + test "admin-down interface that is operationally down is OK", %{ + device: device, + snmp_device: snmp_device + } do + interface = + insert_interface(snmp_device, %{if_index: 3, if_admin_status: "down"}) + + check = build_check(device, interface.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, {:integer, 2}} + end) + + assert {:ok, response} = SnmpInterfaceExecutor.execute(check) + assert response.status == 0 + assert response.output =~ "Down" + end + + test "down interface that should be up is CRITICAL", %{ + device: device, + snmp_device: snmp_device + } do + interface = insert_interface(snmp_device, %{if_index: 4, if_admin_status: "up"}) + check = build_check(device, interface.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, {:integer, 2}} + end) + + assert {:ok, response} = SnmpInterfaceExecutor.execute(check) + assert response.status == 2 + end + end + + describe "execute/1 error paths" do + test "returns error when interface record is missing", %{device: device} do + check = build_check(device, Ecto.UUID.generate()) + + assert {:error, msg} = SnmpInterfaceExecutor.execute(check) + assert is_binary(msg) + assert msg =~ "Interface not found" + end + + test "returns error when device has no SNMP device record", %{ + organization: organization, + snmp_device: snmp_device + } do + interface = insert_interface(snmp_device, %{if_index: 5}) + + {:ok, site} = + Towerops.Sites.create_site(%{name: "No SNMP Site", organization_id: organization.id}) + + {:ok, bare_device} = + Towerops.Devices.create_device(%{ + name: "Bare", + ip_address: "192.0.2.51", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }) + + {:ok, check} = + Monitoring.create_check(%{ + organization_id: organization.id, + device_id: bare_device.id, + name: "Iface", + check_type: "snmp_interface", + source_type: "auto_discovery", + source_id: interface.id, + config: %{} + }) + + assert {:error, msg} = SnmpInterfaceExecutor.execute(check) + assert msg =~ "does not have SNMP configured" + end + + test "propagates SNMP error", %{device: device, snmp_device: snmp_device} do + interface = insert_interface(snmp_device, %{if_index: 6}) + check = build_check(device, interface.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:error, :timeout} + end) + + assert {:error, :timeout} = SnmpInterfaceExecutor.execute(check) + end + + test "returns error when SNMP returns non-integer value", %{ + device: device, + snmp_device: snmp_device + } do + interface = insert_interface(snmp_device, %{if_index: 8}) + check = build_check(device, interface.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, {:octet_string, "not-an-integer"}} + end) + + assert {:error, msg} = SnmpInterfaceExecutor.execute(check) + assert is_binary(msg) + assert msg =~ "Unexpected value type" + end + end +end diff --git a/test/towerops/monitoring/executors/snmp_processor_executor_test.exs b/test/towerops/monitoring/executors/snmp_processor_executor_test.exs new file mode 100644 index 00000000..77549126 --- /dev/null +++ b/test/towerops/monitoring/executors/snmp_processor_executor_test.exs @@ -0,0 +1,231 @@ +defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutorTest do + @moduledoc """ + End-to-end tests for `SnmpProcessorExecutor.execute/1`, driving the full + `with` chain (processor lookup, device + snmp_device preload, SNMP poll, + status mapping, output formatting) using `Towerops.Snmp.SnmpMock`. + """ + use Towerops.DataCase, async: true + + import Mox + import Towerops.AccountsFixtures + + alias Towerops.Monitoring + alias Towerops.Monitoring.Executors.SnmpProcessorExecutor + alias Towerops.Snmp + alias Towerops.Snmp.Processor + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + 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: "Test Device", + ip_address: "192.0.2.30", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }) + + device = Towerops.Devices.get_device!(device.id) + + snmp_device = + %Snmp.Device{} + |> Snmp.Device.changeset(%{ + device_id: device.id, + sys_name: "test-device", + sys_descr: "Test" + }) + |> Repo.insert!() + + %{device: device, snmp_device: snmp_device, organization: organization} + end + + defp insert_processor(snmp_device, attrs) do + base = %{ + snmp_device_id: snmp_device.id, + processor_index: "1", + processor_type: "hr_processor", + description: "CPU 0" + } + + %Processor{} + |> Processor.changeset(Map.merge(base, attrs)) + |> Repo.insert!() + end + + defp build_check(device, source_id) do + {:ok, check} = + Monitoring.create_check(%{ + organization_id: device.organization_id, + device_id: device.id, + name: "CPU load", + check_type: "snmp_processor", + source_type: "auto_discovery", + source_id: source_id, + config: %{} + }) + + check + end + + describe "execute/1 success path" do + test "returns OK status for low load, hr_processor", %{ + device: device, + snmp_device: snmp_device + } do + processor = insert_processor(snmp_device, %{processor_index: "1"}) + check = build_check(device, processor.id) + + expect(SnmpMock, :get, fn _target, oid, _opts -> + # hr_processor uses 1.3.6.1.2.1.25.3.3.1.2. + assert to_string(oid) == "1.3.6.1.2.1.25.3.3.1.2.1" + {:ok, {:integer, 25}} + end) + + assert {:ok, response} = SnmpProcessorExecutor.execute(check) + assert response.value == 25.0 + assert response.status == 0 + assert response.output =~ "CPU 0" + assert response.output =~ "25.0%" + assert is_integer(response.response_time_ms) + end + + test "returns WARNING status for 80-90% load", %{ + device: device, + snmp_device: snmp_device + } do + processor = insert_processor(snmp_device, %{processor_index: "2"}) + check = build_check(device, processor.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, {:integer, 85}} + end) + + assert {:ok, response} = SnmpProcessorExecutor.execute(check) + assert response.value == 85.0 + assert response.status == 1 + end + + test "returns CRITICAL status for >= 90% load", %{ + device: device, + snmp_device: snmp_device + } do + processor = insert_processor(snmp_device, %{processor_index: "3"}) + check = build_check(device, processor.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, {:integer, 95}} + end) + + assert {:ok, response} = SnmpProcessorExecutor.execute(check) + assert response.value == 95.0 + assert response.status == 2 + end + + test "uses Cisco OID for cisco_cpu type", %{ + device: device, + snmp_device: snmp_device + } do + processor = + insert_processor(snmp_device, %{ + processor_index: "7", + processor_type: "cisco_cpu", + description: "Cisco CPU" + }) + + check = build_check(device, processor.id) + + expect(SnmpMock, :get, fn _target, oid, _opts -> + assert to_string(oid) == "1.3.6.1.4.1.9.9.109.1.1.1.1.5.7" + {:ok, {:integer, 10}} + end) + + assert {:ok, response} = SnmpProcessorExecutor.execute(check) + assert response.value == 10.0 + assert response.status == 0 + end + end + + describe "execute/1 error paths" do + test "returns error when processor record is missing", %{device: device} do + check = build_check(device, Ecto.UUID.generate()) + + assert {:error, msg} = SnmpProcessorExecutor.execute(check) + assert is_binary(msg) + assert msg =~ "Processor not found" + end + + test "returns error when device has no SNMP device record", %{ + organization: organization, + snmp_device: snmp_device + } do + processor = insert_processor(snmp_device, %{processor_index: "5"}) + + {:ok, site} = + Towerops.Sites.create_site(%{name: "No SNMP Site", organization_id: organization.id}) + + {:ok, bare_device} = + Towerops.Devices.create_device(%{ + name: "Bare", + ip_address: "192.0.2.52", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }) + + {:ok, check} = + Monitoring.create_check(%{ + organization_id: organization.id, + device_id: bare_device.id, + name: "CPU", + check_type: "snmp_processor", + source_type: "auto_discovery", + source_id: processor.id, + config: %{} + }) + + assert {:error, msg} = SnmpProcessorExecutor.execute(check) + assert msg =~ "does not have SNMP configured" + end + + test "propagates SNMP error", %{device: device, snmp_device: snmp_device} do + processor = insert_processor(snmp_device, %{processor_index: "6"}) + check = build_check(device, processor.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:error, :timeout} + end) + + assert {:error, :timeout} = SnmpProcessorExecutor.execute(check) + end + + test "returns error when SNMP returns non-numeric value", %{ + device: device, + snmp_device: snmp_device + } do + processor = insert_processor(snmp_device, %{processor_index: "8"}) + check = build_check(device, processor.id) + + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, {:octet_string, "garbage"}} + end) + + assert {:error, msg} = SnmpProcessorExecutor.execute(check) + assert is_binary(msg) + assert msg =~ "Unexpected value type" + end + end +end diff --git a/test/towerops/monitoring/executors/snmp_storage_executor_test.exs b/test/towerops/monitoring/executors/snmp_storage_executor_test.exs new file mode 100644 index 00000000..b03c1a7e --- /dev/null +++ b/test/towerops/monitoring/executors/snmp_storage_executor_test.exs @@ -0,0 +1,221 @@ +defmodule Towerops.Monitoring.Executors.SnmpStorageExecutorTest do + @moduledoc """ + End-to-end tests for `SnmpStorageExecutor.execute/1`. + + Drives the full `with` chain — fetching the storage row, the device with its + snmp_device, building SNMP options, and polling — using the configured + `Towerops.Snmp.SnmpMock` (set as `:snmp_adapter` in test config) to stub + SNMP responses without touching the network. + """ + use Towerops.DataCase, async: true + + import Mox + import Towerops.AccountsFixtures + + alias Towerops.Monitoring + alias Towerops.Monitoring.Executors.SnmpStorageExecutor + alias Towerops.Snmp + alias Towerops.Snmp.SnmpMock + alias Towerops.Snmp.Storage + + setup :verify_on_exit! + + 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: "Test Device", + ip_address: "192.0.2.10", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }) + + device = Towerops.Devices.get_device!(device.id) + + snmp_device = + %Snmp.Device{} + |> Snmp.Device.changeset(%{ + device_id: device.id, + sys_name: "test-host", + sys_descr: "Linux Test" + }) + |> Repo.insert!() + + %{device: device, snmp_device: snmp_device, organization: organization} + end + + defp insert_storage(snmp_device, attrs \\ %{}) do + base = %{ + snmp_device_id: snmp_device.id, + storage_index: 1, + storage_type: "fixed_disk", + description: "/", + device_name: "/dev/sda1" + } + + %Storage{} + |> Storage.changeset(Map.merge(base, attrs)) + |> Repo.insert!() + end + + defp build_check(device, source_id) do + {:ok, check} = + Monitoring.create_check(%{ + organization_id: device.organization_id, + device_id: device.id, + name: "Disk usage", + check_type: "snmp_storage", + source_type: "auto_discovery", + source_id: source_id, + config: %{} + }) + + check + end + + # The Client wraps adapter results: get_multiple returns {:ok, %{oid => {:type, value}}}. + defp typed_map(oids, values) do + oids + |> Enum.zip(values) + |> Map.new(fn {oid, val} -> {oid, {:integer, val}} end) + end + + describe "execute/1 success path" do + test "returns standardized response with usage percent and OK status", %{ + device: device, + snmp_device: snmp_device + } do + storage = insert_storage(snmp_device) + check = build_check(device, storage.id) + + # 50% usage: 4096 alloc * 1000 size, 4096 alloc * 500 used → 50% + expect(SnmpMock, :get_multiple, fn _target, oids, _opts -> + assert length(oids) == 3 + [alloc_oid, size_oid, used_oid] = oids + assert String.ends_with?(to_string(alloc_oid), ".4.1") + assert String.ends_with?(to_string(size_oid), ".5.1") + assert String.ends_with?(to_string(used_oid), ".6.1") + + {:ok, typed_map(oids, [4096, 1000, 500])} + end) + + assert {:ok, response} = SnmpStorageExecutor.execute(check) + + assert response.value == 50.0 + assert response.status == 0 + assert is_binary(response.output) + assert String.contains?(response.output, "50.0%") + assert is_integer(response.response_time_ms) + assert response.response_time_ms >= 0 + end + + test "returns CRITICAL status when usage is at or above 95%", %{ + device: device, + snmp_device: snmp_device + } do + storage = insert_storage(snmp_device, %{storage_index: 2, description: "/var"}) + check = build_check(device, storage.id) + + expect(SnmpMock, :get_multiple, fn _target, oids, _opts -> + {:ok, typed_map(oids, [1, 100, 96])} + end) + + assert {:ok, response} = SnmpStorageExecutor.execute(check) + assert response.value == 96.0 + assert response.status == 2 + end + + test "returns WARNING status when usage is between 85-95%", %{ + device: device, + snmp_device: snmp_device + } do + storage = insert_storage(snmp_device, %{storage_index: 3, description: "/home"}) + check = build_check(device, storage.id) + + expect(SnmpMock, :get_multiple, fn _target, oids, _opts -> + {:ok, typed_map(oids, [1, 100, 90])} + end) + + assert {:ok, response} = SnmpStorageExecutor.execute(check) + assert response.value == 90.0 + assert response.status == 1 + end + end + + describe "execute/1 error paths" do + test "returns error when storage record is missing", %{device: device} do + check = build_check(device, Ecto.UUID.generate()) + + assert {:error, msg} = SnmpStorageExecutor.execute(check) + assert is_binary(msg) + assert msg =~ "Storage not found" + end + + test "returns error when device has no SNMP device record", %{ + organization: organization, + snmp_device: snmp_device + } do + # Storage row attached to the existing snmp_device, but the check + # points at a *different* device that has no snmp_device. + storage = insert_storage(snmp_device, %{storage_index: 7}) + + {:ok, site} = + Towerops.Sites.create_site(%{name: "No SNMP Site", organization_id: organization.id}) + + {:ok, bare_device} = + Towerops.Devices.create_device(%{ + name: "Bare", + ip_address: "192.0.2.50", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }) + + {:ok, check} = + Monitoring.create_check(%{ + organization_id: organization.id, + device_id: bare_device.id, + name: "Disk", + check_type: "snmp_storage", + source_type: "auto_discovery", + source_id: storage.id, + config: %{} + }) + + assert {:error, msg} = SnmpStorageExecutor.execute(check) + assert is_binary(msg) + assert msg =~ "does not have SNMP configured" + end + + test "propagates SNMP error when both batched and sequential paths fail", %{ + device: device, + snmp_device: snmp_device + } do + storage = insert_storage(snmp_device, %{storage_index: 9}) + check = build_check(device, storage.id) + + # Batched path fails; Client falls back to sequential get/3 per OID. + expect(SnmpMock, :get_multiple, fn _target, _oids, _opts -> + {:error, :timeout} + end) + + expect(SnmpMock, :get, 3, fn _target, _oid, _opts -> + {:error, :timeout} + end) + + assert {:error, _reason} = SnmpStorageExecutor.execute(check) + end + end +end diff --git a/test/towerops/netbox/sync_integration_test.exs b/test/towerops/netbox/sync_integration_test.exs new file mode 100644 index 00000000..caa24012 --- /dev/null +++ b/test/towerops/netbox/sync_integration_test.exs @@ -0,0 +1,277 @@ +defmodule Towerops.NetBox.SyncIntegrationTest do + @moduledoc """ + Integration tests for `Towerops.NetBox.Sync.sync_organization/1`. + + Pure-function unit tests live in `sync_test.exs`. + """ + use Towerops.DataCase, async: true + + alias Towerops.AccountsFixtures + alias Towerops.Devices + alias Towerops.Integrations + alias Towerops.NetBox.Client + alias Towerops.NetBox.Sync + alias Towerops.OrganizationsFixtures + alias Towerops.Sites + + @url "https://netbox.example.com" + @token "test-token" + + defp setup_integration(extra_creds \\ %{}) do + user = AccountsFixtures.user_fixture() + org = OrganizationsFixtures.organization_fixture(user.id) + + creds = + Map.merge( + %{"url" => @url, "api_token" => @token}, + extra_creds + ) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "netbox", + enabled: true, + credentials: creds + }) + + {org, integration} + end + + defp netbox_site(name, attrs \\ %{}) do + Map.merge( + %{ + "id" => System.unique_integer([:positive]), + "name" => name, + "latitude" => nil, + "longitude" => nil + }, + attrs + ) + end + + defp netbox_device(name, attrs) do + Map.merge( + %{ + "id" => System.unique_integer([:positive]), + "name" => name, + "primary_ip4" => %{"address" => "10.0.0.#{:rand.uniform(254)}/24"}, + "site" => nil, + "role" => nil, + "status" => %{"value" => "active"}, + "serial" => nil + }, + attrs + ) + end + + describe "sync_organization/1 — disabled flags" do + test "returns zero counts and success when neither sync flag is set" do + {_org, integration} = setup_integration() + # No HTTP stub needed — neither branch should make a call. + + assert {:ok, %{sites: 0, devices: 0}} = Sync.sync_organization(integration) + + reloaded = Towerops.Repo.reload!(integration) + assert reloaded.last_sync_status == "success" + assert reloaded.last_sync_message =~ "Synced 0 sites, 0 devices" + end + end + + describe "sync_organization/1 — sites sync" do + test "creates new sites returned by NetBox (no lat/long)" do + {org, integration} = setup_integration(%{"sync_sites" => true}) + + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/dcim/sites/" + + Req.Test.json(conn, %{ + "results" => [netbox_site("HQ"), netbox_site("Edge")] + }) + end) + + assert {:ok, %{sites: 2, devices: 0}} = Sync.sync_organization(integration) + + sites = Sites.list_organization_sites(org.id) + names = sites |> Enum.map(& &1.name) |> Enum.sort() + assert names == ["Edge", "HQ"] + end + + test "updates an existing site (matched by lowercased name)" do + {org, integration} = setup_integration(%{"sync_sites" => "true"}) + existing = OrganizationsFixtures.site_fixture(org.id, %{name: "Hq"}) + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"results" => [netbox_site("HQ")]}) + end) + + assert {:ok, %{sites: 1}} = Sync.sync_organization(integration) + + reloaded = Sites.get_site!(existing.id) + assert reloaded.id == existing.id + assert reloaded.name == "HQ" + end + + test "passes tag filter to the NetBox client when configured" do + {_org, integration} = + setup_integration(%{"sync_sites" => true, "tag_filter" => "production"}) + + test_pid = self() + + Req.Test.stub(Client, fn conn -> + send(test_pid, {:query_string, conn.query_string}) + Req.Test.json(conn, %{"results" => []}) + end) + + assert {:ok, %{sites: 0}} = Sync.sync_organization(integration) + assert_received {:query_string, qs} + assert qs =~ "tag=production" + end + + test "sites HTTP 401 propagates and updates sync status to failed" do + {_org, integration} = setup_integration(%{"sync_sites" => true}) + + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Sync.sync_organization(integration) + + reloaded = Towerops.Repo.reload!(integration) + assert reloaded.last_sync_status == "failed" + assert reloaded.last_sync_message =~ "Authentication failed" + end + end + + describe "sync_organization/1 — devices sync" do + test "creates devices and matches sites by name" do + {org, integration} = + setup_integration(%{"sync_sites" => true, "sync_devices" => true}) + + site_payload = netbox_site("DC1") + + device_payload = + netbox_device("router-01", %{ + "primary_ip4" => %{"address" => "10.0.0.1/24"}, + "site" => %{"name" => "DC1"}, + "role" => %{"name" => "core-router"}, + "status" => %{"value" => "active"} + }) + + Req.Test.stub(Client, fn conn -> + case conn.request_path do + "/api/dcim/sites/" -> + Req.Test.json(conn, %{"results" => [site_payload]}) + + "/api/dcim/devices/" -> + Req.Test.json(conn, %{"results" => [device_payload]}) + end + end) + + assert {:ok, %{sites: 1, devices: 1}} = Sync.sync_organization(integration) + + [device] = Devices.list_organization_devices(org.id) + assert device.name == "router-01" + assert to_string(device.ip_address) == "10.0.0.1" + assert device.device_role == "core-router" + assert device.status == :up + assert device.site_id + end + + test "device-only sync skips sites endpoint" do + {_org, integration} = setup_integration(%{"sync_devices" => true}) + + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/dcim/devices/" + Req.Test.json(conn, %{"results" => []}) + end) + + assert {:ok, %{sites: 0, devices: 0}} = Sync.sync_organization(integration) + end + + test "passes role/site/tag filters to the device endpoint" do + {_org, integration} = + setup_integration(%{ + "sync_devices" => true, + "device_role_filter" => "ap", + "site_filter" => "edge", + "tag_filter" => "prod" + }) + + test_pid = self() + + Req.Test.stub(Client, fn conn -> + send(test_pid, {:device_qs, conn.query_string}) + Req.Test.json(conn, %{"results" => []}) + end) + + assert {:ok, %{devices: 0}} = Sync.sync_organization(integration) + assert_received {:device_qs, qs} + assert qs =~ "role=ap" + assert qs =~ "site=edge" + assert qs =~ "tag=prod" + end + + test "device sync HTTP 404 updates sync status to failed" do + {_org, integration} = setup_integration(%{"sync_devices" => true}) + + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(404) + |> Req.Test.json(%{}) + end) + + assert {:error, :not_found} = Sync.sync_organization(integration) + + reloaded = Towerops.Repo.reload!(integration) + assert reloaded.last_sync_status == "failed" + assert reloaded.last_sync_message =~ "API endpoint not found" + end + + test "counts only successful upserts; logs (and skips) failed ones" do + {org, integration} = setup_integration(%{"sync_devices" => true}) + + # Good device: valid IP, valid name, non-null role + good = + netbox_device("good-router", %{"role" => %{"name" => "router"}}) + + # Bad device: missing IP triggers validate_required([:ip_address, ...]) + bad = + netbox_device("bad-router", %{ + "primary_ip4" => nil, + "primary_ip" => nil, + "role" => %{"name" => "router"} + }) + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"results" => [good, bad]}) + end) + + # Logger level is :error in test config, so the warning isn't captured. + # We only assert that the count reflects the successful upsert. + ExUnit.CaptureLog.capture_log(fn -> + assert {:ok, %{devices: 1}} = Sync.sync_organization(integration) + end) + + [device] = Devices.list_organization_devices(org.id) + assert device.name == "good-router" + end + end + + describe "sync_organization/1 — humanizes transport errors" do + test "Req transport error becomes a generic failed message" do + {_org, integration} = setup_integration(%{"sync_sites" => true}) + + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _reason} = Sync.sync_organization(integration) + + reloaded = Towerops.Repo.reload!(integration) + assert reloaded.last_sync_status == "failed" + assert is_binary(reloaded.last_sync_message) + end + end +end diff --git a/test/towerops/organizations/membership_query_test.exs b/test/towerops/organizations/membership_query_test.exs new file mode 100644 index 00000000..9d1402dd --- /dev/null +++ b/test/towerops/organizations/membership_query_test.exs @@ -0,0 +1,106 @@ +defmodule Towerops.Organizations.MembershipQueryTest do + use Towerops.DataCase, async: true + use ExUnitProperties + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Organizations + alias Towerops.Organizations.Membership + alias Towerops.Organizations.MembershipQuery + alias Towerops.Repo + + setup do + owner = user_fixture() + org = organization_fixture(owner.id) + + other = user_fixture() + + {:ok, _} = + Organizations.create_membership(%{ + organization_id: org.id, + user_id: other.id, + role: :technician + }) + + %{owner: owner, other: other, org: org} + end + + describe "base/0" do + test "is the Membership schema" do + assert MembershipQuery.base() == Membership + end + end + + describe "for_organization/2" do + test "scopes to a single organization", %{org: org} do + results = + MembershipQuery.base() + |> MembershipQuery.for_organization(org.id) + |> Repo.all() + + assert Enum.all?(results, &(&1.organization_id == org.id)) + assert length(results) >= 2 + end + + test "returns [] for unknown org" do + assert [] = Ecto.UUID.generate() |> MembershipQuery.for_organization() |> Repo.all() + end + end + + describe "for_user/2" do + test "scopes to a single user", %{owner: owner, org: org} do + [result] = + MembershipQuery.base() + |> MembershipQuery.for_organization(org.id) + |> MembershipQuery.for_user(owner.id) + |> Repo.all() + + assert result.user_id == owner.id + end + end + + describe "with_role/2 and with_role_in/2" do + test "with_role narrows to one role", %{org: org} do + [result] = + MembershipQuery.base() + |> MembershipQuery.for_organization(org.id) + |> MembershipQuery.with_role(:owner) + |> Repo.all() + + assert result.role == :owner + end + + test "with_role_in matches any in list", %{org: org} do + results = + MembershipQuery.base() + |> MembershipQuery.for_organization(org.id) + |> MembershipQuery.with_role_in([:owner, :technician]) + |> Repo.all() + + roles = MapSet.new(results, & &1.role) + assert MapSet.member?(roles, :owner) + assert MapSet.member?(roles, :technician) + end + + property "with_role_in([role]) returns same set as with_role(role)", %{org: org} do + check all(role <- StreamData.member_of([:owner, :technician]), max_runs: 5) do + a = + MembershipQuery.base() + |> MembershipQuery.for_organization(org.id) + |> MembershipQuery.with_role(role) + |> Repo.all() + |> MapSet.new(& &1.id) + + b = + MembershipQuery.base() + |> MembershipQuery.for_organization(org.id) + |> MembershipQuery.with_role_in([role]) + |> Repo.all() + |> MapSet.new(& &1.id) + + assert a == b + end + end + end +end diff --git a/test/towerops/snmp/arp_entries_test.exs b/test/towerops/snmp/arp_entries_test.exs new file mode 100644 index 00000000..85ca8c11 --- /dev/null +++ b/test/towerops/snmp/arp_entries_test.exs @@ -0,0 +1,147 @@ +defmodule Towerops.Snmp.ArpEntriesTest do + use Towerops.DataCase, async: true + + import Towerops.DevicesFixtures + + alias Towerops.Repo + alias Towerops.Snmp.ArpEntries + alias Towerops.Snmp.ArpEntry + + setup do + device = device_fixture() + %{device: device, organization_id: device.organization_id} + end + + describe "list_arp_entries/1" do + test "returns ARP entries ordered by IP", %{device: device} do + _ = upsert!(device.id, "192.168.1.10", "AA:AA:AA:AA:AA:01") + _ = upsert!(device.id, "192.168.1.5", "AA:AA:AA:AA:AA:02") + + result = ArpEntries.list_arp_entries(device.id) + assert Enum.map(result, & &1.ip_address) == ["192.168.1.10", "192.168.1.5"] + end + + test "returns [] for unknown device" do + assert ArpEntries.list_arp_entries(Ecto.UUID.generate()) == [] + end + end + + describe "get_arp_entry/1" do + test "returns the entry by id", %{device: device} do + e = upsert!(device.id, "192.168.1.5", "AA:AA:AA:AA:AA:01") + assert %ArpEntry{id: id} = ArpEntries.get_arp_entry(e.id) + assert id == e.id + end + + test "returns nil for unknown id" do + assert is_nil(ArpEntries.get_arp_entry(Ecto.UUID.generate())) + end + end + + describe "upsert_arp_entry/1" do + test "creates new entry", %{device: device} do + attrs = %{ + device_id: device.id, + ip_address: "192.168.1.5", + mac_address: "AA:AA:AA:AA:AA:01", + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, %ArpEntry{ip_address: "192.168.1.5"}} = ArpEntries.upsert_arp_entry(attrs) + end + + test "upserts on (device_id, ip_address, mac_address) conflict", %{device: device} do + attrs = %{ + device_id: device.id, + ip_address: "192.168.1.5", + mac_address: "AA:AA:AA:AA:AA:01", + entry_type: "dynamic", + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, %{id: id}} = ArpEntries.upsert_arp_entry(attrs) + attrs2 = %{attrs | entry_type: "static"} + assert {:ok, %{id: ^id, entry_type: "static"}} = ArpEntries.upsert_arp_entry(attrs2) + assert Repo.aggregate(ArpEntry, :count) == 1 + end + end + + describe "delete_stale_arp_entries/2" do + test "deletes entries older than cutoff", %{device: device} do + _ = upsert!(device.id, "10.0.0.1", "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z]) + _ = upsert!(device.id, "10.0.0.2", "AA:AA:AA:AA:AA:02", last_seen_at: ~U[2026-01-10 00:00:00Z]) + + assert {1, _} = ArpEntries.delete_stale_arp_entries(device.id, ~U[2026-01-05 00:00:00Z]) + assert Repo.aggregate(ArpEntry, :count) == 1 + end + end + + describe "upsert_arp_entries/3" do + test "counts successes when no interface match", %{device: device} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + entries = [ + %{ip_address: "10.0.0.1", mac_address: "AA:AA:AA:AA:AA:01", if_index: 1, last_seen_at: now}, + %{ip_address: "10.0.0.2", mac_address: "AA:AA:AA:AA:AA:02", if_index: 2, last_seen_at: now} + ] + + assert {2, 0} = ArpEntries.upsert_arp_entries(device.id, entries, []) + end + + test "counts errors when changeset fails", %{device: device} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + entries = [ + %{ip_address: nil, mac_address: nil, if_index: 1, last_seen_at: now}, + %{ip_address: "10.0.0.2", mac_address: "AA:AA:AA:AA:AA:02", if_index: 1, last_seen_at: now} + ] + + previous = Logger.level() + Logger.configure(level: :warning) + on_exit(fn -> Logger.configure(level: previous) end) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {1, 1} = ArpEntries.upsert_arp_entries(device.id, entries, []) + end) + + assert log =~ "ARP upsert failures" + end + end + + describe "delete_stale_and_upsert_arp_entries/4" do + test "wraps delete + upsert in a transaction", %{device: device} do + _ = upsert!(device.id, "10.0.0.1", "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z]) + now = DateTime.truncate(DateTime.utc_now(), :second) + + new_entries = [ + %{ip_address: "10.0.0.99", mac_address: "AA:AA:AA:AA:AA:99", if_index: 1, last_seen_at: now} + ] + + assert {:ok, {1, 0}} = + ArpEntries.delete_stale_and_upsert_arp_entries( + device.id, + new_entries, + [], + ~U[2026-01-05 00:00:00Z] + ) + + assert Repo.aggregate(ArpEntry, :count) == 1 + end + end + + defp upsert!(device_id, ip, mac, extra \\ []) do + last_seen = Keyword.get(extra, :last_seen_at, DateTime.truncate(DateTime.utc_now(), :second)) + + attrs = + Enum.into(extra, %{ + device_id: device_id, + ip_address: ip, + mac_address: mac, + last_seen_at: last_seen + }) + + {:ok, e} = ArpEntries.upsert_arp_entry(attrs) + e + end +end diff --git a/test/towerops/snmp/ip_addresses_test.exs b/test/towerops/snmp/ip_addresses_test.exs new file mode 100644 index 00000000..43052890 --- /dev/null +++ b/test/towerops/snmp/ip_addresses_test.exs @@ -0,0 +1,90 @@ +defmodule Towerops.Snmp.IpAddressesTest do + use Towerops.DataCase, async: true + + import Towerops.SnmpFixtures + + alias Towerops.Repo + alias Towerops.Snmp.Interface + alias Towerops.Snmp.IpAddress + alias Towerops.Snmp.IpAddresses + + setup do + snmp_device = snmp_device_fixture() + iface = insert_interface!(snmp_device.id, 1) + %{snmp_device: snmp_device, interface: iface} + end + + describe "list_ip_addresses/1" do + test "returns IPs across all interfaces of a device", %{snmp_device: snmp_device, interface: iface} do + _ = insert_ip!(iface.id, "192.168.1.1", "ipv4") + _ = insert_ip!(iface.id, "10.0.0.1", "ipv4") + + iface2 = insert_interface!(snmp_device.id, 2) + _ = insert_ip!(iface2.id, "fe80::1", "ipv6") + + result = IpAddresses.list_ip_addresses(snmp_device.id) + assert length(result) == 3 + assert Enum.all?(result, &Ecto.assoc_loaded?(&1.snmp_interface)) + end + + test "scopes results to the requested device", %{snmp_device: snmp_device, interface: iface} do + _ = insert_ip!(iface.id, "10.0.0.1", "ipv4") + + other_device = snmp_device_fixture() + other_iface = insert_interface!(other_device.id, 1) + _ = insert_ip!(other_iface.id, "10.0.0.2", "ipv4") + + assert [%{ip_address: ip}] = IpAddresses.list_ip_addresses(snmp_device.id) + assert ip == "10.0.0.1" + end + + test "returns [] for device with no IPs" do + assert IpAddresses.list_ip_addresses(Ecto.UUID.generate()) == [] + end + + test "orders by ip_type then ip_address", %{snmp_device: snmp_device, interface: iface} do + _ = insert_ip!(iface.id, "192.168.1.5", "ipv4") + _ = insert_ip!(iface.id, "192.168.1.1", "ipv4") + _ = insert_ip!(iface.id, "fe80::1", "ipv6") + + result = IpAddresses.list_ip_addresses(snmp_device.id) + assert Enum.map(result, & &1.ip_address) == ["192.168.1.1", "192.168.1.5", "fe80::1"] + end + end + + describe "list_interface_ip_addresses/1" do + test "returns IPs only for that interface", %{interface: iface} do + _ = insert_ip!(iface.id, "192.168.1.1", "ipv4") + _ = insert_ip!(iface.id, "192.168.1.2", "ipv4") + + result = IpAddresses.list_interface_ip_addresses(iface.id) + assert length(result) == 2 + end + + test "returns [] for unknown interface" do + assert IpAddresses.list_interface_ip_addresses(Ecto.UUID.generate()) == [] + end + end + + defp insert_interface!(snmp_device_id, idx) do + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device_id, + if_index: idx, + if_name: "eth#{idx}", + if_descr: "Ethernet #{idx}" + }) + |> Repo.insert!() + end + + defp insert_ip!(interface_id, address, type) do + %IpAddress{} + |> IpAddress.changeset(%{ + snmp_interface_id: interface_id, + ip_address: address, + ip_type: type, + prefix_length: if(type == "ipv4", do: 24, else: 64) + }) + |> Repo.insert!() + end +end diff --git a/test/towerops/snmp/mempools_test.exs b/test/towerops/snmp/mempools_test.exs new file mode 100644 index 00000000..574ee422 --- /dev/null +++ b/test/towerops/snmp/mempools_test.exs @@ -0,0 +1,189 @@ +defmodule Towerops.Snmp.MempoolsTest do + use Towerops.DataCase, async: true + + import Towerops.SnmpFixtures + + alias Towerops.Repo + alias Towerops.Snmp.Mempool + alias Towerops.Snmp.MempoolReading + alias Towerops.Snmp.Mempools + + setup do + snmp_device = snmp_device_fixture() + %{snmp_device: snmp_device} + end + + describe "list_mempools/1" do + test "returns mempools for a device, ordered by mempool_index", %{snmp_device: snmp_device} do + _ = insert_mempool!(snmp_device.id, "2") + _ = insert_mempool!(snmp_device.id, "1") + _ = insert_mempool!(snmp_device.id, "3") + + result = Mempools.list_mempools(snmp_device.id) + assert Enum.map(result, & &1.mempool_index) == ["1", "2", "3"] + end + + test "scopes results to the requested device", %{snmp_device: snmp_device} do + other = snmp_device_fixture() + keep = insert_mempool!(snmp_device.id, "1") + _drop = insert_mempool!(other.id, "1") + + assert [%{id: id}] = Mempools.list_mempools(snmp_device.id) + assert id == keep.id + end + + test "returns [] for a device with no mempools" do + assert Mempools.list_mempools(Ecto.UUID.generate()) == [] + end + end + + describe "update_mempool/2" do + test "applies attribute changes", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + + assert {:ok, updated} = Mempools.update_mempool(mempool, %{usage_percent: 42.0, used_bytes: 100}) + assert updated.usage_percent == 42.0 + assert updated.used_bytes == 100 + end + end + + describe "get_mempool_readings/2" do + test "returns most recent first, capped by :limit", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + + _r1 = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z]) + r2 = insert_reading!(mempool.id, ~U[2026-01-02 00:00:00Z]) + r3 = insert_reading!(mempool.id, ~U[2026-01-03 00:00:00Z]) + + result = Mempools.get_mempool_readings(mempool.id, limit: 2) + assert Enum.map(result, & &1.id) == [r3.id, r2.id] + end + + test "filters readings by :since", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + _old = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z]) + newish = insert_reading!(mempool.id, ~U[2026-01-05 00:00:00Z]) + + result = Mempools.get_mempool_readings(mempool.id, since: ~U[2026-01-04 00:00:00Z]) + assert Enum.map(result, & &1.id) == [newish.id] + end + + test "ignores readings from other mempools", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + other_mempool = insert_mempool!(snmp_device.id, "2") + r = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z]) + _ = insert_reading!(other_mempool.id, ~U[2026-01-01 00:00:00Z]) + + assert [%{id: id}] = Mempools.get_mempool_readings(mempool.id) + assert id == r.id + end + end + + describe "get_latest_mempool_reading/1" do + test "returns the latest reading for a single mempool", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + _ = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z]) + latest = insert_reading!(mempool.id, ~U[2026-01-09 00:00:00Z]) + + assert %{id: id} = Mempools.get_latest_mempool_reading(mempool.id) + assert id == latest.id + end + + test "returns nil when no readings exist" do + assert Mempools.get_latest_mempool_reading(Ecto.UUID.generate()) == nil + end + end + + describe "get_latest_mempool_readings_batch/1" do + test "returns %{} for an empty list" do + assert Mempools.get_latest_mempool_readings_batch([]) == %{} + end + + test "returns latest reading per mempool keyed by mempool_id", %{snmp_device: snmp_device} do + m1 = insert_mempool!(snmp_device.id, "1") + m2 = insert_mempool!(snmp_device.id, "2") + m3 = insert_mempool!(snmp_device.id, "3") + + _ = insert_reading!(m1.id, ~U[2026-01-01 00:00:00Z]) + m1_latest = insert_reading!(m1.id, ~U[2026-01-05 00:00:00Z]) + m2_latest = insert_reading!(m2.id, ~U[2026-01-02 00:00:00Z]) + + result = Mempools.get_latest_mempool_readings_batch([m1.id, m2.id, m3.id]) + assert result[m1.id].id == m1_latest.id + assert result[m2.id].id == m2_latest.id + refute Map.has_key?(result, m3.id) + end + end + + describe "create_mempool_reading/1" do + test "inserts a single reading", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + + attrs = %{ + mempool_id: mempool.id, + used_bytes: 100, + total_bytes: 1000, + free_bytes: 900, + usage_percent: 10.0, + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, %MempoolReading{} = reading} = Mempools.create_mempool_reading(attrs) + assert reading.used_bytes == 100 + end + + test "returns error for invalid attrs" do + assert {:error, %Ecto.Changeset{}} = Mempools.create_mempool_reading(%{}) + end + end + + describe "create_mempool_readings_batch/1" do + test "returns {0, nil} for empty list" do + assert Mempools.create_mempool_readings_batch([]) == {0, nil} + end + + test "inserts many readings in one shot", %{snmp_device: snmp_device} do + mempool = insert_mempool!(snmp_device.id, "1") + now = Towerops.Time.now() + + entries = + for n <- 1..3 do + %{ + mempool_id: mempool.id, + used_bytes: n * 100, + total_bytes: 1000, + free_bytes: 1000 - n * 100, + usage_percent: n * 10.0, + checked_at: now + } + end + + assert {3, nil} = Mempools.create_mempool_readings_batch(entries) + assert Repo.aggregate(MempoolReading, :count, :id) == 3 + end + end + + defp insert_mempool!(snmp_device_id, index) do + %Mempool{} + |> Mempool.changeset(%{ + snmp_device_id: snmp_device_id, + mempool_index: index, + mempool_type: "hr_memory", + description: "Memory pool #{index}" + }) + |> Repo.insert!() + end + + defp insert_reading!(mempool_id, checked_at) do + %MempoolReading{} + |> MempoolReading.changeset(%{ + mempool_id: mempool_id, + used_bytes: 100, + total_bytes: 1000, + free_bytes: 900, + usage_percent: 10.0, + checked_at: checked_at + }) + |> Repo.insert!() + end +end diff --git a/test/towerops/snmp/neighbors_test.exs b/test/towerops/snmp/neighbors_test.exs new file mode 100644 index 00000000..a21b6622 --- /dev/null +++ b/test/towerops/snmp/neighbors_test.exs @@ -0,0 +1,206 @@ +defmodule Towerops.Snmp.NeighborsTest do + use Towerops.DataCase, async: true + + import Towerops.DevicesFixtures + import Towerops.SnmpFixtures + + alias Towerops.Repo + alias Towerops.Snmp.Interface + alias Towerops.Snmp.Neighbor + alias Towerops.Snmp.Neighbors + + setup do + device = device_fixture() + snmp_device = snmp_device_fixture(%{device: device}) + interface = insert_interface!(snmp_device.id, 1, "AA:BB:CC:DD:EE:FF") + + %{ + device: device, + organization_id: device.organization_id, + snmp_device: snmp_device, + interface: interface + } + end + + describe "list_neighbors/1" do + test "returns neighbors for a device, ordered by protocol then remote_system_name", %{ + device: device, + interface: iface + } do + _ = upsert_neighbor!(device.id, iface.id, "lldp", "alpha") + _ = upsert_neighbor!(device.id, iface.id, "cdp", "beta") + + result = Neighbors.list_neighbors(device.id) + assert Enum.map(result, & &1.protocol) == ["cdp", "lldp"] + end + + test "returns [] for unknown device" do + assert Neighbors.list_neighbors(Ecto.UUID.generate()) == [] + end + end + + describe "get_neighbor/1" do + test "fetches by id", %{device: device, interface: iface} do + n = upsert_neighbor!(device.id, iface.id, "lldp", "alpha") + assert %Neighbor{id: id} = Neighbors.get_neighbor(n.id) + assert id == n.id + end + + test "returns nil for unknown id" do + assert is_nil(Neighbors.get_neighbor(Ecto.UUID.generate())) + end + end + + describe "upsert_neighbor/1" do + test "creates new neighbor", %{device: device, interface: iface} do + attrs = %{ + device_id: device.id, + interface_id: iface.id, + protocol: "lldp", + remote_chassis_id: "11:22:33:44:55:66", + remote_system_name: "switch-01", + last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, neighbor} = Neighbors.upsert_neighbor(attrs) + assert neighbor.remote_system_name == "switch-01" + end + + test "second upsert with same conflict_target updates the row", %{device: device, interface: iface} do + attrs = %{ + device_id: device.id, + interface_id: iface.id, + protocol: "lldp", + remote_chassis_id: "11:22:33:44:55:66", + remote_system_name: "old-name", + last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, %{id: id}} = Neighbors.upsert_neighbor(attrs) + attrs2 = %{attrs | remote_system_name: "new-name"} + assert {:ok, %{id: ^id, remote_system_name: "new-name"}} = Neighbors.upsert_neighbor(attrs2) + assert Repo.aggregate(Neighbor, :count) == 1 + end + end + + describe "delete_stale_neighbors/2" do + test "deletes neighbors older than cutoff", %{device: device, interface: iface} do + _ = upsert_neighbor!(device.id, iface.id, "lldp", "alpha", last_discovered_at: ~U[2026-01-01 00:00:00Z]) + _ = upsert_neighbor!(device.id, iface.id, "cdp", "beta", last_discovered_at: ~U[2026-01-10 00:00:00Z]) + + assert {1, _} = Neighbors.delete_stale_neighbors(device.id, ~U[2026-01-05 00:00:00Z]) + assert Repo.aggregate(Neighbor, :count) == 1 + end + end + + describe "delete_stale_and_upsert_neighbors/3" do + test "wraps delete + upsert in a transaction", %{device: device, interface: iface} do + _ = upsert_neighbor!(device.id, iface.id, "lldp", "alpha", last_discovered_at: ~U[2026-01-01 00:00:00Z]) + + now = DateTime.truncate(DateTime.utc_now(), :second) + + new_neighbors = [ + %{ + device_id: device.id, + interface_id: iface.id, + protocol: "lldp", + remote_chassis_id: "AA:BB:CC:DD:EE:FF", + remote_system_name: "fresh", + last_discovered_at: now + } + ] + + assert {:ok, :ok} = + Neighbors.delete_stale_and_upsert_neighbors( + device.id, + new_neighbors, + ~U[2026-01-05 00:00:00Z] + ) + + assert Repo.aggregate(Neighbor, :count) == 1 + end + + test "logs but tolerates a bad neighbor entry", %{device: device, interface: iface} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + bad_neighbor = %{ + device_id: device.id, + interface_id: iface.id, + protocol: nil, + remote_chassis_id: nil, + last_discovered_at: now + } + + previous = Logger.level() + Logger.configure(level: :error) + on_exit(fn -> Logger.configure(level: previous) end) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {:ok, :ok} = + Neighbors.delete_stale_and_upsert_neighbors( + device.id, + [bad_neighbor], + ~U[2026-01-05 00:00:00Z] + ) + end) + + assert log =~ "Failed to upsert neighbor" + end + end + + describe "list_neighbors_with_equipment/1 — match by name" do + test "matches a neighbor by remote_system_name to a device in the same org", %{ + device: device, + interface: iface, + organization_id: org_id + } do + neighbor_target = device_fixture(%{organization_id: org_id, name: "switch-21"}) + + _ = + upsert_neighbor!(device.id, iface.id, "lldp", "switch-21", remote_chassis_id: "AA:BB:CC:DD:EE:00") + + [enriched] = Neighbors.list_neighbors_with_equipment(device.id) + assert enriched.matched_device.id == neighbor_target.id + end + + test "returns nil match when no device matches", %{ + device: device, + interface: iface + } do + _ = upsert_neighbor!(device.id, iface.id, "lldp", "no-such-host") + + [enriched] = Neighbors.list_neighbors_with_equipment(device.id) + assert is_nil(enriched.matched_device) + end + end + + defp upsert_neighbor!(device_id, interface_id, protocol, system_name, extra \\ []) do + chassis = Keyword.get(extra, :remote_chassis_id, "AA:BB:CC:DD:EE:#{Enum.random(10..99)}") + last = Keyword.get(extra, :last_discovered_at, DateTime.truncate(DateTime.utc_now(), :second)) + + attrs = %{ + device_id: device_id, + interface_id: interface_id, + protocol: protocol, + remote_chassis_id: chassis, + remote_system_name: system_name, + last_discovered_at: last + } + + {:ok, n} = Neighbors.upsert_neighbor(attrs) + n + end + + defp insert_interface!(snmp_device_id, idx, mac) do + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device_id, + if_index: idx, + if_name: "eth#{idx}", + if_descr: "Ethernet #{idx}", + if_phys_address: mac + }) + |> Repo.insert!() + end +end diff --git a/test/towerops/snmp/processors_test.exs b/test/towerops/snmp/processors_test.exs new file mode 100644 index 00000000..d31ea58c --- /dev/null +++ b/test/towerops/snmp/processors_test.exs @@ -0,0 +1,114 @@ +defmodule Towerops.Snmp.ProcessorsTest do + use Towerops.DataCase, async: true + + import Towerops.SnmpFixtures + + alias Towerops.Repo + alias Towerops.Snmp.Processor + alias Towerops.Snmp.ProcessorReading + alias Towerops.Snmp.Processors + + setup do + snmp_device = snmp_device_fixture() + %{snmp_device: snmp_device} + end + + describe "list_processors/1" do + test "returns processors for a device, ordered by processor_index", %{snmp_device: snmp_device} do + _ = insert_processor!(snmp_device.id, "2") + _ = insert_processor!(snmp_device.id, "1") + _ = insert_processor!(snmp_device.id, "3") + + result = Processors.list_processors(snmp_device.id) + assert Enum.map(result, & &1.processor_index) == ["1", "2", "3"] + end + + test "scopes to the requested device", %{snmp_device: snmp_device} do + other = snmp_device_fixture() + keep = insert_processor!(snmp_device.id, "1") + _drop = insert_processor!(other.id, "1") + + assert [%{id: id}] = Processors.list_processors(snmp_device.id) + assert id == keep.id + end + + test "returns [] for a device with no processors" do + assert Processors.list_processors(Ecto.UUID.generate()) == [] + end + end + + describe "get_processor/1" do + test "returns the processor by id", %{snmp_device: snmp_device} do + processor = insert_processor!(snmp_device.id, "1") + assert %Processor{id: id} = Processors.get_processor(processor.id) + assert id == processor.id + end + + test "returns nil for unknown id" do + assert is_nil(Processors.get_processor(Ecto.UUID.generate())) + end + end + + describe "update_processor/2" do + test "applies attribute changes", %{snmp_device: snmp_device} do + processor = insert_processor!(snmp_device.id, "1") + + assert {:ok, updated} = Processors.update_processor(processor, %{load_percent: 42.0}) + assert updated.load_percent == 42.0 + end + end + + describe "get_processor_readings/2" do + test "returns most recent first, capped by :limit", %{snmp_device: snmp_device} do + processor = insert_processor!(snmp_device.id, "1") + + _r1 = insert_reading!(processor.id, ~U[2026-01-01 00:00:00Z]) + r2 = insert_reading!(processor.id, ~U[2026-01-02 00:00:00Z]) + r3 = insert_reading!(processor.id, ~U[2026-01-03 00:00:00Z]) + + result = Processors.get_processor_readings(processor.id, limit: 2) + assert Enum.map(result, & &1.id) == [r3.id, r2.id] + end + + test "filters readings by :since", %{snmp_device: snmp_device} do + processor = insert_processor!(snmp_device.id, "1") + _old = insert_reading!(processor.id, ~U[2026-01-01 00:00:00Z]) + newish = insert_reading!(processor.id, ~U[2026-01-05 00:00:00Z]) + + result = Processors.get_processor_readings(processor.id, since: ~U[2026-01-04 00:00:00Z]) + assert Enum.map(result, & &1.id) == [newish.id] + end + + test "ignores readings from other processors", %{snmp_device: snmp_device} do + processor = insert_processor!(snmp_device.id, "1") + other_processor = insert_processor!(snmp_device.id, "2") + r = insert_reading!(processor.id, ~U[2026-01-01 00:00:00Z]) + _ = insert_reading!(other_processor.id, ~U[2026-01-01 00:00:00Z]) + + assert [%{id: id}] = Processors.get_processor_readings(processor.id) + assert id == r.id + end + end + + defp insert_processor!(snmp_device_id, index) do + %Processor{} + |> Processor.changeset(%{ + snmp_device_id: snmp_device_id, + processor_index: index, + processor_type: "hr_processor", + description: "CPU #{index}" + }) + |> Repo.insert!() + end + + defp insert_reading!(processor_id, checked_at) do + %ProcessorReading{} + |> ProcessorReading.changeset(%{ + processor_id: processor_id, + load_percent: 50.0, + status: "ok", + checked_at: checked_at + }) + |> Repo.insert!() + end +end diff --git a/test/towerops/snmp/wireless_client_discovery_test.exs b/test/towerops/snmp/wireless_client_discovery_test.exs index 8280c87c..43d0fb33 100644 --- a/test/towerops/snmp/wireless_client_discovery_test.exs +++ b/test/towerops/snmp/wireless_client_discovery_test.exs @@ -1,31 +1,136 @@ defmodule Towerops.Snmp.WirelessClientDiscoveryTest do @moduledoc """ - Unit tests for the main module. Pure helpers are already covered in the - Parser submodule test file. Here we test the dispatch and the `:ok, []` - fall-through for unknown vendor profiles. + Tests for vendor-specific dispatch in `WirelessClientDiscovery`. + + The full per-vendor SNMP walks are exercised through a stub adapter + installed via `:adapter` option in `client_opts`, which the SNMP Client + honors as a test seam. """ use ExUnit.Case, async: true alias Towerops.Snmp.WirelessClientDiscovery + defmodule StubAdapter do + @moduledoc false + + def put_walks(map), do: Process.put(:wcd_walks, map) + + # Adapter contract: walk(opts, oid) → {:ok, [{oid_str, value}, ...]} + def walk(_opts, oid) do + walks = Process.get(:wcd_walks, %{}) + pairs = Map.get(walks, oid, []) + {:ok, pairs} + end + + def get(_opts, _oid), do: {:ok, nil} + end + + defp opts_with_stub, do: [adapter: StubAdapter] + describe "discover_wireless_clients/2 dispatch" do test "unknown vendor yields empty list without SNMP calls" do - # `client_opts` would only be used if an SNMP walk were attempted. - # Passing nil here proves the unknown-vendor branch short-circuits first. assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, "unknown") assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, nil) assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, "") end end + describe "discover_wireless_clients/2 with stub adapter" do + setup do + StubAdapter.put_walks(%{}) + :ok + end + + test "ePMP profile returns clients for known walks" do + base = "1.3.6.1.4.1.17713.21.1.2.30.1" + + StubAdapter.put_walks(%{ + "#{base}.1" => [{"#{base}.1.1.1", <<170, 187, 204, 221, 238, 255>>}], + "#{base}.10" => [{"#{base}.10.1.1", "192.168.1.10"}], + "#{base}.5" => [{"#{base}.5.1.1", -55}] + }) + + assert {:ok, clients} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "epmp") + + assert is_list(clients) + end + + test "ubnt airOS profile returns clients for known walks" do + base = "1.3.6.1.4.1.41112.1.4.7.1" + + StubAdapter.put_walks(%{ + "#{base}.1" => [{"#{base}.1.1.1", <<170, 187, 204, 221, 238, 255>>}], + "#{base}.2" => [{"#{base}.2.1.1", "client-host"}] + }) + + assert {:ok, clients} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "airos") + + assert is_list(clients) + end + + test "mikrotik routeros profile returns clients for known walks" do + base = "1.3.6.1.4.1.14988.1.1.1.2.1" + + StubAdapter.put_walks(%{ + "#{base}.1" => [{"#{base}.1.1.1", <<170, 187, 204, 221, 238, 255>>}] + }) + + assert {:ok, clients} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "routeros") + + assert is_list(clients) + end + + test "ePMP with no walk data returns empty list" do + assert {:ok, []} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "epmp") + end + + test "ubnt with no walk data returns empty list" do + assert {:ok, []} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "airos") + end + + test "routeros with no walk data returns empty list" do + assert {:ok, []} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "routeros") + end + + test "ePMP aliases (cambium, pmp) all dispatch to ePMP code path" do + for profile <- ["epmp", "cambium", "pmp"] do + assert {:ok, []} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), profile) + end + end + + test "ubnt aliases all dispatch to ubnt code path" do + for profile <- ["airos", "airfiber", "airos-af", "airos-af60", "airos-af-ltu"] do + assert {:ok, []} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), profile) + end + end + + test "mikrotik aliases dispatch to mikrotik code path" do + for profile <- ["routeros", "mikrotik"] do + assert {:ok, []} = + WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), profile) + end + end + end + describe "detect_vendor_profile/1 (delegated)" do test "returns nil for empty device" do assert nil == WirelessClientDiscovery.detect_vendor_profile(%{}) - assert nil == WirelessClientDiscovery.detect_vendor_profile(%{sys_object_id: nil, manufacturer: nil}) + + assert nil == + WirelessClientDiscovery.detect_vendor_profile(%{sys_object_id: nil, manufacturer: nil}) end test "detects MikroTik" do - assert "routeros" == WirelessClientDiscovery.detect_vendor_profile(%{manufacturer: "MikroTik", sys_object_id: nil}) + assert "routeros" == + WirelessClientDiscovery.detect_vendor_profile(%{manufacturer: "MikroTik", sys_object_id: nil}) end end end diff --git a/test/towerops/snmp/wireless_client_reading_test.exs b/test/towerops/snmp/wireless_client_reading_test.exs new file mode 100644 index 00000000..95160949 --- /dev/null +++ b/test/towerops/snmp/wireless_client_reading_test.exs @@ -0,0 +1,108 @@ +defmodule Towerops.Snmp.WirelessClientReadingTest do + use ExUnit.Case, async: true + + alias Towerops.Snmp.WirelessClientReading + + describe "changeset/2" do + test "valid with required fields" do + attrs = %{ + device_id: Ecto.UUID.generate(), + wireless_client_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + mac_address: "AA:BB:CC:DD:EE:FF", + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + assert changeset.valid? + end + + test "valid with all metric fields" do + attrs = %{ + device_id: Ecto.UUID.generate(), + wireless_client_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + mac_address: "AA:BB:CC:DD:EE:FF", + ip_address: "192.168.1.10", + signal_strength: -55, + snr: 25, + distance: 1500, + tx_rate: 1200, + rx_rate: 800, + uptime_seconds: 3600, + metadata: %{"vendor" => "ubiquiti_airos"}, + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + assert changeset.valid? + assert changeset.changes.metadata == %{"vendor" => "ubiquiti_airos"} + assert changeset.changes.signal_strength == -55 + end + + test "invalid without device_id" do + attrs = %{ + wireless_client_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + mac_address: "AA:BB:CC:DD:EE:FF", + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + refute changeset.valid? + assert {:device_id, _} = List.keyfind(changeset.errors, :device_id, 0) + end + + test "invalid without wireless_client_id" do + attrs = %{ + device_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + mac_address: "AA:BB:CC:DD:EE:FF", + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + refute changeset.valid? + assert {:wireless_client_id, _} = List.keyfind(changeset.errors, :wireless_client_id, 0) + end + + test "invalid without organization_id" do + attrs = %{ + device_id: Ecto.UUID.generate(), + wireless_client_id: Ecto.UUID.generate(), + mac_address: "AA:BB:CC:DD:EE:FF", + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + refute changeset.valid? + assert {:organization_id, _} = List.keyfind(changeset.errors, :organization_id, 0) + end + + test "invalid without mac_address" do + attrs = %{ + device_id: Ecto.UUID.generate(), + wireless_client_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + checked_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + refute changeset.valid? + assert {:mac_address, _} = List.keyfind(changeset.errors, :mac_address, 0) + end + + test "invalid without checked_at" do + attrs = %{ + device_id: Ecto.UUID.generate(), + wireless_client_id: Ecto.UUID.generate(), + organization_id: Ecto.UUID.generate(), + mac_address: "AA:BB:CC:DD:EE:FF" + } + + changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs) + refute changeset.valid? + assert {:checked_at, _} = List.keyfind(changeset.errors, :checked_at, 0) + end + end +end diff --git a/test/towerops/snmp/wireless_clients_test.exs b/test/towerops/snmp/wireless_clients_test.exs new file mode 100644 index 00000000..ab256c68 --- /dev/null +++ b/test/towerops/snmp/wireless_clients_test.exs @@ -0,0 +1,210 @@ +defmodule Towerops.Snmp.WirelessClientsTest do + use Towerops.DataCase, async: true + + import Towerops.DevicesFixtures + + alias Towerops.Repo + alias Towerops.Snmp.WirelessClient + alias Towerops.Snmp.WirelessClients + + setup do + device = device_fixture() + %{device: device, organization_id: device.organization_id} + end + + describe "list_wireless_clients/1" do + test "returns clients ordered by mac_address", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "BB:BB:BB:BB:BB:BB") + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:AA") + + result = WirelessClients.list_wireless_clients(device.id) + assert Enum.map(result, & &1.mac_address) == ["AA:AA:AA:AA:AA:AA", "BB:BB:BB:BB:BB:BB"] + end + + test "returns [] for unknown device" do + assert WirelessClients.list_wireless_clients(Ecto.UUID.generate()) == [] + end + end + + describe "get_wireless_clients_by_mac/2" do + test "returns matching clients in org", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:AA") + _ = upsert!(device.id, org_id, "BB:BB:BB:BB:BB:BB") + + result = + WirelessClients.get_wireless_clients_by_mac(org_id, [ + "AA:AA:AA:AA:AA:AA", + "ZZ:ZZ:ZZ:ZZ:ZZ:ZZ" + ]) + + assert Enum.map(result, & &1.mac_address) == ["AA:AA:AA:AA:AA:AA"] + end + end + + describe "upsert_wireless_client/1" do + test "creates a new entry", %{device: device, organization_id: org_id} do + assert {:ok, client} = + WirelessClients.upsert_wireless_client(%{ + device_id: device.id, + organization_id: org_id, + mac_address: "AA:BB:CC:DD:EE:FF", + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + assert client.mac_address == "AA:BB:CC:DD:EE:FF" + end + + test "upserts on (device_id, mac_address) conflict", %{device: device, organization_id: org_id} do + attrs1 = %{ + device_id: device.id, + organization_id: org_id, + mac_address: "AA:BB:CC:DD:EE:FF", + signal_strength: -50, + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, %{id: id}} = WirelessClients.upsert_wireless_client(attrs1) + + attrs2 = Map.put(attrs1, :signal_strength, -80) + assert {:ok, %{id: ^id, signal_strength: -80}} = WirelessClients.upsert_wireless_client(attrs2) + assert Repo.aggregate(WirelessClient, :count) == 1 + end + end + + describe "upsert_wireless_clients/3" do + test "counts successes and failures", %{device: device, organization_id: org_id} do + good = %{mac_address: "AA:AA:AA:AA:AA:01", last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)} + bad = %{mac_address: nil, last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)} + + assert {1, 1} = WirelessClients.upsert_wireless_clients(device.id, org_id, [good, bad]) + end + + test "empty list returns {0, 0}", %{device: device, organization_id: org_id} do + assert {0, 0} = WirelessClients.upsert_wireless_clients(device.id, org_id, []) + end + end + + describe "delete_stale_wireless_clients/2" do + test "deletes entries with last_seen_at < cutoff", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z]) + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02", last_seen_at: ~U[2026-01-10 00:00:00Z]) + + cutoff = ~U[2026-01-05 00:00:00Z] + + assert {1, _} = WirelessClients.delete_stale_wireless_clients(device.id, cutoff) + assert Repo.aggregate(WirelessClient, :count) == 1 + end + end + + describe "delete_stale_and_upsert_wireless_clients/4" do + test "deletes stale + upserts new in one transaction", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z]) + + now = DateTime.truncate(DateTime.utc_now(), :second) + new_clients = [%{mac_address: "BB:BB:BB:BB:BB:BB", last_seen_at: now}] + + cutoff = ~U[2026-01-05 00:00:00Z] + + assert {:ok, {1, 0}} = + WirelessClients.delete_stale_and_upsert_wireless_clients( + device.id, + org_id, + new_clients, + cutoff + ) + + assert Repo.aggregate(WirelessClient, :count) == 1 + end + end + + describe "create_wireless_client_readings_batch/1" do + test "empty list returns {0, nil}" do + assert {0, nil} = WirelessClients.create_wireless_client_readings_batch([]) + end + + @tag :skip + test "inserts readings with generated UUIDs and timestamps", %{device: device, organization_id: org_id} do + # Skipped: WirelessClientReading.device_id references snmp_devices, not devices, + # which makes a minimal fixture awkward. The empty-list path covers the function head we care about. + _ = {device, org_id} + :ok + end + end + + describe "list_weak_signal_clients/2" do + test "returns clients with signal below threshold", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", signal_strength: -50) + weak = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02", signal_strength: -90) + + result = WirelessClients.list_weak_signal_clients(org_id, -75) + assert Enum.map(result, & &1.id) == [weak.id] + end + end + + describe "list_low_snr_clients/2" do + test "returns clients with snr below threshold", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", snr: 30) + poor = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02", snr: 5) + + result = WirelessClients.list_low_snr_clients(org_id, 15) + assert Enum.map(result, & &1.id) == [poor.id] + end + end + + describe "get_wireless_client_count_by_device/1" do + test "returns map of device → count", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01") + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02") + + assert %{} = result = WirelessClients.get_wireless_client_count_by_device(org_id) + assert result[device.id] == 2 + end + end + + describe "list_overloaded_aps/2" do + test "returns devices with > threshold clients", %{device: device, organization_id: org_id} do + Enum.each(1..3, fn n -> + upsert!(device.id, org_id, "AA:AA:AA:AA:AA:0#{n}") + end) + + assert [{ap, 3}] = WirelessClients.list_overloaded_aps(org_id, 2) + assert ap.id == device.id + end + + test "returns [] when no AP exceeds threshold", %{device: device, organization_id: org_id} do + _ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01") + assert [] = WirelessClients.list_overloaded_aps(org_id, 100) + end + end + + describe "get_wireless_client_last_seen/2" do + test "returns most recent last_seen_at for the MAC, case-insensitive", %{device: device, organization_id: org_id} do + newest = ~U[2026-01-10 12:00:00Z] + _ = upsert!(device.id, org_id, "AA:BB:CC:DD:EE:FF", last_seen_at: ~U[2026-01-01 12:00:00Z]) + + device2 = device_fixture(%{organization_id: org_id}) + _ = upsert!(device2.id, org_id, "AA:BB:CC:DD:EE:FF", last_seen_at: newest) + + assert WirelessClients.get_wireless_client_last_seen(org_id, "aa:bb:cc:dd:ee:ff") == newest + end + + test "returns nil for unknown MAC", %{organization_id: org_id} do + assert is_nil(WirelessClients.get_wireless_client_last_seen(org_id, "00:00:00:00:00:00")) + end + end + + defp upsert!(device_id, org_id, mac, extra \\ []) do + last_seen = Keyword.get(extra, :last_seen_at, DateTime.truncate(DateTime.utc_now(), :second)) + + attrs = + Enum.into(extra, %{ + device_id: device_id, + organization_id: org_id, + mac_address: mac, + last_seen_at: last_seen + }) + + {:ok, client} = WirelessClients.upsert_wireless_client(attrs) + client + end +end diff --git a/test/towerops/sonar/sync_integration_test.exs b/test/towerops/sonar/sync_integration_test.exs new file mode 100644 index 00000000..3ecc5da2 --- /dev/null +++ b/test/towerops/sonar/sync_integration_test.exs @@ -0,0 +1,118 @@ +defmodule Towerops.Sonar.SyncIntegrationTest do + @moduledoc """ + Drives `Towerops.Sonar.Sync.sync_organization/1` against `Req.Test`-stubbed + GraphQL responses. Pure helpers (humanize_sync_error / sum_service_prices / + add_price / calculate_total_mrr) are covered in `sync_test.exs`. + """ + use Towerops.DataCase, async: false + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations + alias Towerops.Sonar.Client + alias Towerops.Sonar.Sync + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(organization.id, %{ + provider: "sonar", + enabled: true, + credentials: %{ + "instance_url" => "https://sonar.example.com", + "api_token" => "test-token-xyz" + } + }) + + %{integration: integration, organization: organization} + end + + defp empty_page_result(root_key) do + %{ + "data" => %{ + root_key => %{ + "entities" => [], + "page_info" => %{"total_pages" => 1, "page" => 1} + } + } + } + end + + defp graphql_handler(stub_responses) do + fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + query = Jason.decode!(body)["query"] + + response = + cond do + String.contains?(query, "accounts") -> stub_responses[:accounts] + String.contains?(query, "network_sites") -> stub_responses[:network_sites] + String.contains?(query, "inventory_items") -> stub_responses[:inventory_items] + true -> %{"data" => %{}} + end + + Req.Test.json(conn, response) + end + end + + describe "sync_organization/1 — happy path" do + test "returns counts and updates the integration to success", %{integration: integration} do + Req.Test.stub( + Client, + graphql_handler(%{ + accounts: empty_page_result("accounts"), + network_sites: empty_page_result("network_sites"), + inventory_items: empty_page_result("inventory_items") + }) + ) + + assert {:ok, summary} = Sync.sync_organization(integration) + assert summary == %{accounts: 0, network_sites: 0, inventory_items: 0} + + reloaded = Towerops.Repo.reload(integration) + assert reloaded.last_sync_status == "success" + end + end + + describe "sync_organization/1 — error paths" do + test "401 sets status=failed and returns {:error, :unauthorized}", %{integration: integration} do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Sync.sync_organization(integration) + reloaded = Towerops.Repo.reload(integration) + assert reloaded.last_sync_status == "failed" + assert reloaded.last_sync_message =~ "Authentication failed" + end + + test "503 with retry-after sets status=failed", %{integration: integration} do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "60") + |> Plug.Conn.put_status(429) + |> Req.Test.json(%{}) + end) + + assert {:error, _} = Sync.sync_organization(integration) + reloaded = Towerops.Repo.reload(integration) + assert reloaded.last_sync_status == "failed" + end + + test "GraphQL errors propagate and are humanized", %{integration: integration} do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "errors" => [%{"message" => "no permission"}, %{"message" => "bad query"}] + }) + end) + + assert {:error, _} = Sync.sync_organization(integration) + reloaded = Towerops.Repo.reload(integration) + assert reloaded.last_sync_status == "failed" + assert reloaded.last_sync_message =~ "GraphQL errors" + end + end +end diff --git a/test/towerops/status_pages/status_incident_test.exs b/test/towerops/status_pages/status_incident_test.exs new file mode 100644 index 00000000..7084bd66 --- /dev/null +++ b/test/towerops/status_pages/status_incident_test.exs @@ -0,0 +1,81 @@ +defmodule Towerops.StatusPages.StatusIncidentTest do + use ExUnit.Case, async: true + + alias Towerops.StatusPages.StatusIncident + + describe "changeset/2" do + test "valid with required fields" do + attrs = %{ + title: "Outage in core", + status: "investigating", + started_at: DateTime.truncate(DateTime.utc_now(), :second), + status_page_config_id: Ecto.UUID.generate() + } + + assert StatusIncident.changeset(%StatusIncident{}, attrs).valid? + end + + test "rejects missing required fields" do + attrs = %{} + changeset = StatusIncident.changeset(%StatusIncident{}, attrs) + refute changeset.valid? + + keys = Keyword.keys(changeset.errors) + assert :title in keys + assert :started_at in keys + assert :status_page_config_id in keys + end + + test "rejects invalid severity" do + attrs = %{ + title: "x", + status: "investigating", + severity: "catastrophic", + started_at: DateTime.truncate(DateTime.utc_now(), :second), + status_page_config_id: Ecto.UUID.generate() + } + + changeset = StatusIncident.changeset(%StatusIncident{}, attrs) + refute changeset.valid? + assert {:severity, _} = List.keyfind(changeset.errors, :severity, 0) + end + + test "rejects invalid status" do + attrs = %{ + title: "x", + status: "nuked", + started_at: DateTime.truncate(DateTime.utc_now(), :second), + status_page_config_id: Ecto.UUID.generate() + } + + changeset = StatusIncident.changeset(%StatusIncident{}, attrs) + refute changeset.valid? + assert {:status, _} = List.keyfind(changeset.errors, :status, 0) + end + + test "accepts each valid severity and status" do + base_attrs = %{ + title: "x", + started_at: DateTime.truncate(DateTime.utc_now(), :second), + status_page_config_id: Ecto.UUID.generate() + } + + for severity <- StatusIncident.valid_severities() do + attrs = base_attrs |> Map.put(:severity, severity) |> Map.put(:status, "investigating") + assert StatusIncident.changeset(%StatusIncident{}, attrs).valid?, "expected severity #{severity} to be valid" + end + + for status <- StatusIncident.valid_statuses() do + attrs = Map.put(base_attrs, :status, status) + assert StatusIncident.changeset(%StatusIncident{}, attrs).valid?, "expected status #{status} to be valid" + end + end + end + + describe "valid_severities/0 and valid_statuses/0" do + test "return canonical lists" do + assert StatusIncident.valid_severities() == ~w(minor major critical) + assert StatusIncident.valid_statuses() == ~w(investigating identified monitoring resolved) + end + end +end diff --git a/test/towerops/workers/alert_digest_worker_test.exs b/test/towerops/workers/alert_digest_worker_test.exs index ac0b8c3d..74bd4804 100644 --- a/test/towerops/workers/alert_digest_worker_test.exs +++ b/test/towerops/workers/alert_digest_worker_test.exs @@ -7,6 +7,8 @@ defmodule Towerops.Workers.AlertDigestWorkerTest do alias Towerops.Alerts.NotificationDigest alias Towerops.Workers.AlertDigestWorker + require Logger + defp insert_digest!(opts) do user_id = Keyword.fetch!(opts, :user_id) digest_sent = Keyword.get(opts, :digest_sent, false) @@ -58,6 +60,45 @@ defmodule Towerops.Workers.AlertDigestWorkerTest do user = user_fixture() assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id}) end + + test "no-op when digest exists but suppressed alert IDs are stale (alerts deleted)" do + user = user_fixture() + stale_id = Ecto.UUID.generate() + _ = insert_digest!(user_id: user.id, alert_ids: [stale_id]) + + # alert_ids reference a UUID that does not exist; list_alerts_by_ids returns [] + assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id}) + end + + test "delivers digest when real alerts exist for the user" do + import Towerops.DevicesFixtures + + user = user_fixture() + device = device_fixture() + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second), + message: "down" + }) + + digest = insert_digest!(user_id: user.id, alert_ids: [alert.id]) + + previous = Logger.level() + Logger.configure(level: :info) + on_exit(fn -> Logger.configure(level: previous) end) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id}) + end) + + assert log =~ "1 suppressed alerts" + reloaded = Towerops.Repo.get!(NotificationDigest, digest.id) + assert reloaded.digest_sent + end end describe "enqueue/1" do diff --git a/test/towerops/workers/alert_notification_worker_routing_test.exs b/test/towerops/workers/alert_notification_worker_routing_test.exs new file mode 100644 index 00000000..dc1d3fad --- /dev/null +++ b/test/towerops/workers/alert_notification_worker_routing_test.exs @@ -0,0 +1,117 @@ +defmodule Towerops.Workers.AlertNotificationWorkerRoutingTest do + @moduledoc """ + Drives `AlertNotificationWorker.perform/1` through routing variations + ("builtin", "pagerduty", "both") and lifecycle actions + (trigger, acknowledge, resolve) using real DB fixtures. + + PagerDuty calls fall through to {:error, :not_configured} since no + integration is provisioned. These tests verify the worker stays :ok in + every branch and never crashes. + """ + use Towerops.DataCase, async: false + + import ExUnit.CaptureLog + + alias Towerops.Alerts + alias Towerops.Alerts.Alert + alias Towerops.Organizations + alias Towerops.Repo + alias Towerops.Workers.AlertNotificationWorker + + setup do + user = Towerops.AccountsFixtures.user_fixture() + {:ok, org} = Organizations.create_organization(%{name: "Notif Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "S-#{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + name: "D-#{System.unique_integer([:positive])}" + }) + + {:ok, alert} = + Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.utc_now(), + message: "device unreachable" + }) + + %{org: org, device: device, alert: alert, user: user} + end + + defp set_routing!(org, routing) do + {:ok, _} = Organizations.update_organization(org, %{alert_routing: routing}) + :ok + end + + defp trigger_job(alert), do: %Oban.Job{args: %{"action" => "trigger", "alert_id" => alert.id}} + defp ack_job(alert), do: %Oban.Job{args: %{"action" => "acknowledge", "alert_id" => alert.id}} + defp resolve_job(alert), do: %Oban.Job{args: %{"action" => "resolve", "alert_id" => alert.id}} + + describe "trigger" do + test "default builtin routing returns :ok", %{alert: alert} do + log = capture_log(fn -> assert :ok = AlertNotificationWorker.perform(trigger_job(alert)) end) + assert is_binary(log) + end + + test "pagerduty routing without integration returns :ok", %{org: org, alert: alert} do + :ok = set_routing!(org, "pagerduty") + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(trigger_job(alert)) end) + end + + test "both routing exercises both branches", %{org: org, alert: alert} do + :ok = set_routing!(org, "both") + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(trigger_job(alert)) end) + end + + test "storm-suppressed alert short-circuits to :ok", %{alert: alert} do + suppressed = + alert + |> Alert.changeset(%{storm_suppressed: true}) + |> Repo.update!() + + capture_log(fn -> + assert :ok = AlertNotificationWorker.perform(trigger_job(suppressed)) + end) + end + end + + describe "acknowledge" do + test "builtin routing succeeds with no incident open", %{alert: alert} do + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(ack_job(alert)) end) + end + + test "pagerduty routing without integration returns :ok", %{org: org, alert: alert} do + :ok = set_routing!(org, "pagerduty") + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(ack_job(alert)) end) + end + + test "both routing returns :ok", %{org: org, alert: alert} do + :ok = set_routing!(org, "both") + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(ack_job(alert)) end) + end + end + + describe "resolve" do + test "builtin routing returns :ok with no incident", %{alert: alert} do + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(resolve_job(alert)) end) + end + + test "pagerduty routing without integration returns :ok", %{org: org, alert: alert} do + :ok = set_routing!(org, "pagerduty") + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(resolve_job(alert)) end) + end + + test "both routing returns :ok", %{org: org, alert: alert} do + :ok = set_routing!(org, "both") + capture_log(fn -> assert :ok = AlertNotificationWorker.perform(resolve_job(alert)) end) + end + end +end diff --git a/test/towerops/workers/cn_maestro_sync_worker_test.exs b/test/towerops/workers/cn_maestro_sync_worker_test.exs new file mode 100644 index 00000000..5a67712e --- /dev/null +++ b/test/towerops/workers/cn_maestro_sync_worker_test.exs @@ -0,0 +1,117 @@ +defmodule Towerops.Workers.CnMaestroSyncWorkerTest do + use Towerops.DataCase, async: false + use Oban.Testing, repo: Towerops.Repo + + import ExUnit.CaptureLog + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.CnMaestro.Client + alias Towerops.Integrations + alias Towerops.Workers.CnMaestroSyncWorker + + describe "perform/1 dispatcher (empty args)" do + test "returns :ok and enqueues nothing when no integrations exist" do + assert :ok = perform_job(CnMaestroSyncWorker, %{}) + assert [] = all_enqueued(worker: CnMaestroSyncWorker) + end + + test "skips disabled cn_maestro integrations" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, _disabled} = + Integrations.create_integration(org.id, %{ + provider: "cn_maestro", + enabled: false, + credentials: %{ + "url" => "https://cn.example.com", + "client_id" => "id", + "client_secret" => "secret" + } + }) + + assert :ok = perform_job(CnMaestroSyncWorker, %{}) + # Only enabled integrations are enqueued; disabled is skipped. + assert [] = all_enqueued(worker: CnMaestroSyncWorker) + end + + test "enqueues a per-integration job for each enabled cn_maestro integration" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "cn_maestro", + enabled: true, + credentials: %{ + "url" => "https://cn.example.com", + "client_id" => "id", + "client_secret" => "secret" + } + }) + + assert :ok = perform_job(CnMaestroSyncWorker, %{}) + + jobs = all_enqueued(worker: CnMaestroSyncWorker) + assert length(jobs) == 1 + [job] = jobs + assert job.args == %{"integration_id" => integration.id} + end + + test "ignores integrations from other providers" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, _other} = + Integrations.create_integration(org.id, %{ + provider: "preseem", + enabled: true, + credentials: %{"api_key" => "k"} + }) + + assert :ok = perform_job(CnMaestroSyncWorker, %{}) + assert [] = all_enqueued(worker: CnMaestroSyncWorker) + end + end + + describe "perform/1 (with integration_id)" do + test "returns :ok when integration not found" do + missing_id = Ecto.UUID.generate() + + capture_log(fn -> + assert :ok = perform_job(CnMaestroSyncWorker, %{"integration_id" => missing_id}) + end) + end + + test "swallows sync errors and returns :ok (so Oban doesn't retry transient HTTP errors forever)" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "cn_maestro", + enabled: true, + credentials: %{ + "url" => "https://cn.example.com", + "client_id" => "id", + "client_secret" => "secret" + } + }) + + # Stub HTTP boundary: token endpoint returns 401 + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + capture_log(fn -> + assert :ok = perform_job(CnMaestroSyncWorker, %{"integration_id" => integration.id}) + end) + + # Worker swallows sync errors and reports :ok so transient failures don't + # exhaust Oban retries; the integration's status should now be "failed". + {:ok, refreshed} = Integrations.get_integration_by_id(integration.id) + assert refreshed.last_sync_status == "failed" + end + end +end diff --git a/test/towerops/workers/job_cleanup_task_test.exs b/test/towerops/workers/job_cleanup_task_test.exs new file mode 100644 index 00000000..74724a19 --- /dev/null +++ b/test/towerops/workers/job_cleanup_task_test.exs @@ -0,0 +1,31 @@ +defmodule Towerops.Workers.JobCleanupTaskTest do + use Towerops.DataCase, async: false + + alias Towerops.Workers.JobCleanupTask + + setup do + previous_env = Application.get_env(:towerops, :env) + + on_exit(fn -> + if previous_env do + Application.put_env(:towerops, :env, previous_env) + else + Application.delete_env(:towerops, :env) + end + end) + + :ok + end + + describe "run/0" do + test "skips work in non-prod environment" do + Application.put_env(:towerops, :env, :test) + assert :ok == JobCleanupTask.run() || JobCleanupTask.run() == nil + end + + test "skips work in :dev environment" do + Application.put_env(:towerops, :env, :dev) + assert JobCleanupTask.run() == :ok || JobCleanupTask.run() == nil + end + end +end diff --git a/test/towerops/workers/mikrotik_backup_worker_test.exs b/test/towerops/workers/mikrotik_backup_worker_test.exs index ec42e5a4..27a8b2a9 100644 --- a/test/towerops/workers/mikrotik_backup_worker_test.exs +++ b/test/towerops/workers/mikrotik_backup_worker_test.exs @@ -2,11 +2,63 @@ defmodule Towerops.Workers.MikrotikBackupWorkerTest do use Towerops.DataCase, async: false use Oban.Testing, repo: Towerops.Repo + import ExUnit.CaptureLog + import Towerops.AccountsFixtures + + alias Towerops.Devices + alias Towerops.Devices.BackupRequests alias Towerops.Workers.MikrotikBackupWorker describe "perform/1" do test "returns :ok when no eligible devices exist" do assert :ok = perform_job(MikrotikBackupWorker, %{}) end + + test "returns :ok when devices exist but none have MikroTik creds" do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Org-A"}, user.id) + {:ok, site} = Towerops.Sites.create_site(%{name: "Site-A", organization_id: org.id}) + + # Plain non-MikroTik device — must be excluded + {:ok, _device} = + Devices.create_device(%{ + name: "Plain Device", + ip_address: "10.0.0.10", + site_id: site.id, + organization_id: org.id, + mikrotik_enabled: false + }) + + assert :ok = perform_job(MikrotikBackupWorker, %{}) + end + + test "skips device when it has MikroTik creds but no agent assigned" do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Org-B"}, user.id) + {:ok, site} = Towerops.Sites.create_site(%{name: "Site-B", organization_id: org.id}) + + {:ok, device} = + Devices.create_device(%{ + name: "MikroTik No Agent", + ip_address: "10.0.0.20", + site_id: site.id, + organization_id: org.id, + mikrotik_enabled: true, + mikrotik_username: "admin", + mikrotik_password: "secret", + mikrotik_port: 8729, + mikrotik_use_ssl: true + }) + + # No agent assignment — backup_device should return {:skipped, :no_agent}. + # Use capture_log to suppress noise; the worker still returns :ok + # because the device is "skipped", not "errored". + capture_log(fn -> + assert :ok = perform_job(MikrotikBackupWorker, %{}) + end) + + # No backup request should have been created since we skipped + assert BackupRequests.get_request_by_job_id("backup:#{device.id}:0") == nil + end end end diff --git a/test/towerops/workers/report_worker_test.exs b/test/towerops/workers/report_worker_test.exs index ba974caf..e16d25ab 100644 --- a/test/towerops/workers/report_worker_test.exs +++ b/test/towerops/workers/report_worker_test.exs @@ -73,5 +73,47 @@ defmodule Towerops.Workers.ReportWorkerTest do test "logs warning for missing report and returns :ok" do assert :ok = perform_job(ReportWorker, %{"report_id" => Ecto.UUID.generate()}) end + + test "generates and delivers an uptime_summary report (success path)", %{org: org} do + report = create_report(org, %{name: "uptime", report_type: "uptime_summary"}) + + assert :ok = perform_job(ReportWorker, %{"report_id" => report.id}) + + # mark_run("success") should have updated last_run_at + {:ok, refreshed} = Reports.get_report(report.id) + assert refreshed.last_run_at + assert refreshed.last_run_status == "success" + end + + test "generates an alert_history report", %{org: org} do + report = create_report(org, %{name: "alerts", report_type: "alert_history"}) + + assert :ok = perform_job(ReportWorker, %{"report_id" => report.id}) + + {:ok, refreshed} = Reports.get_report(report.id) + assert refreshed.last_run_status == "success" + end + + test "rescue clause records 'failed' status when report_type is unknown", %{org: org} do + # Create a report with valid type, then force-update to invalid type at the + # DB level to bypass changeset validation. This drives the `case` in + # generate_csv/1 into FunctionClauseError, which the worker rescues. + report = create_report(org, %{name: "broken"}) + + Towerops.Repo.update_all( + from(r in Towerops.Reports.Report, where: r.id == ^report.id), + set: [report_type: "no_such_type"] + ) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = perform_job(ReportWorker, %{"report_id" => report.id}) + end) + + assert log =~ "generation failed" + + {:ok, refreshed} = Reports.get_report(report.id) + assert refreshed.last_run_status == "failed" + end end end diff --git a/test/towerops/workers/uisp_sync_worker_test.exs b/test/towerops/workers/uisp_sync_worker_test.exs new file mode 100644 index 00000000..a2a8d57b --- /dev/null +++ b/test/towerops/workers/uisp_sync_worker_test.exs @@ -0,0 +1,150 @@ +defmodule Towerops.Workers.UispSyncWorkerTest do + use Towerops.DataCase, async: false + use Oban.Testing, repo: Towerops.Repo + + import ExUnit.CaptureLog + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations + alias Towerops.Uisp.Client + alias Towerops.Workers.UispSyncWorker + + describe "perform/1 dispatcher (empty args)" do + test "returns :ok with no integrations" do + assert :ok = perform_job(UispSyncWorker, %{}) + assert [] = all_enqueued(worker: UispSyncWorker) + end + + test "skips disabled uisp integrations" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, _disabled} = + Integrations.create_integration(org.id, %{ + provider: "uisp", + enabled: false, + credentials: %{"url" => "https://uisp.example.com", "api_key" => "k"} + }) + + assert :ok = perform_job(UispSyncWorker, %{}) + assert [] = all_enqueued(worker: UispSyncWorker) + end + + test "enqueues per-integration jobs for enabled UISP integrations that are due" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "uisp", + enabled: true, + credentials: %{"url" => "https://uisp.example.com", "api_key" => "k"} + }) + + assert :ok = perform_job(UispSyncWorker, %{}) + + jobs = all_enqueued(worker: UispSyncWorker) + assert length(jobs) == 1 + [job] = jobs + assert job.args == %{"integration_id" => integration.id} + end + + test "does not enqueue an integration that was just synced" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "uisp", + enabled: true, + sync_interval_minutes: 60, + credentials: %{"url" => "https://uisp.example.com", "api_key" => "k"} + }) + + # Mark it freshly synced + {:ok, _} = + Integrations.update_integration(integration, %{ + last_synced_at: DateTime.utc_now(), + last_sync_status: "success" + }) + + assert :ok = perform_job(UispSyncWorker, %{}) + assert [] = all_enqueued(worker: UispSyncWorker) + end + end + + describe "perform/1 (with integration_id)" do + test "returns {:error, :not_found} for missing integration" do + missing_id = Ecto.UUID.generate() + + capture_log(fn -> + assert {:error, :not_found} = + perform_job(UispSyncWorker, %{"integration_id" => missing_id}) + end) + end + + test "swallows API errors and returns :ok (so Oban doesn't retry forever)" do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "uisp", + enabled: true, + credentials: %{ + "url" => "https://uisp.example.com/nms/api/v2.1", + "api_key" => "bad-key" + } + }) + + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + capture_log(fn -> + assert :ok = perform_job(UispSyncWorker, %{"integration_id" => integration.id}) + end) + + # Worker swallows sync errors and reports :ok; verify the integration + # status was updated to "failed" via the sync log path. + {:ok, refreshed} = Integrations.get_integration_by_id(integration.id) + assert refreshed.last_sync_status == "failed" + end + end + + describe "should_sync?/1" do + test "true when last_synced_at is nil" do + assert UispSyncWorker.should_sync?(%{ + last_synced_at: nil, + sync_interval_minutes: 10 + }) + end + + test "false when last_synced_at is recent" do + refute UispSyncWorker.should_sync?(%{ + last_synced_at: DateTime.utc_now(), + sync_interval_minutes: 10 + }) + end + + test "true once enough time elapsed" do + long_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + + assert UispSyncWorker.should_sync?(%{ + last_synced_at: long_ago, + sync_interval_minutes: 10 + }) + end + + test "defaults sync_interval_minutes to 10 when nil" do + # 11 minutes ago should be due with the default + eleven_min_ago = DateTime.add(DateTime.utc_now(), -11 * 60, :second) + + assert UispSyncWorker.should_sync?(%{ + last_synced_at: eleven_min_ago, + sync_interval_minutes: nil + }) + end + end +end diff --git a/test/towerops/workers/weather_sync_worker_test.exs b/test/towerops/workers/weather_sync_worker_test.exs new file mode 100644 index 00000000..5ff1442a --- /dev/null +++ b/test/towerops/workers/weather_sync_worker_test.exs @@ -0,0 +1,41 @@ +defmodule Towerops.Workers.WeatherSyncWorkerTest do + use Towerops.DataCase, async: false + + alias Towerops.Workers.WeatherSyncWorker + + setup do + previous = Application.get_env(:towerops, :openweathermap_api_key) + + on_exit(fn -> + if previous do + Application.put_env(:towerops, :openweathermap_api_key, previous) + else + Application.delete_env(:towerops, :openweathermap_api_key) + end + end) + + :ok + end + + describe "perform/1" do + test "returns :ok and skips work when no API key is configured" do + Application.delete_env(:towerops, :openweathermap_api_key) + assert :ok = WeatherSyncWorker.perform(%Oban.Job{}) + end + + test "returns :ok and skips work when API key is empty string" do + Application.put_env(:towerops, :openweathermap_api_key, "") + assert :ok = WeatherSyncWorker.perform(%Oban.Job{}) + end + + test "returns :ok with no sites when API key is set" do + # No sites with coordinates exist; sync_all_sites should no-op happily. + Application.put_env(:towerops, :openweathermap_api_key, "test-key-not-real") + + # Stub the Weather + Sites HTTP boundary by ensuring there are no sites with + # coordinates so fetch_and_store is never called. The worker still goes + # through cleanup_old_observations and schedule_next. + assert :ok = WeatherSyncWorker.perform(%Oban.Job{}) + end + end +end diff --git a/test/towerops_web/graphql/resolvers/authenticated_test.exs b/test/towerops_web/graphql/resolvers/authenticated_test.exs new file mode 100644 index 00000000..58aac420 --- /dev/null +++ b/test/towerops_web/graphql/resolvers/authenticated_test.exs @@ -0,0 +1,501 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.AuthenticatedTest do + @moduledoc """ + Direct invocation tests for happy and not-found paths of GraphQL resolvers. + Skips the Absinthe pipeline so we can test resolver logic in isolation. + """ + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.AgentsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Repo + alias ToweropsWeb.GraphQL.Resolvers + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{user: user, organization: org, ctx: %{context: %{organization_id: org.id, user: user}}} + end + + describe "Activity.list/3" do + test "returns ok-tuple for authenticated context", %{ctx: ctx} do + assert {:ok, items} = Resolvers.Activity.list(nil, %{}, ctx) + assert is_list(items) + end + + test "ignores invalid type strings", %{ctx: ctx} do + assert {:ok, _items} = Resolvers.Activity.list(nil, %{type: "definitely_not_an_atom_xyz"}, ctx) + end + + test "accepts known-existing atom type", %{ctx: ctx} do + _ = :alert_created + assert {:ok, _items} = Resolvers.Activity.list(nil, %{type: "alert_created"}, ctx) + end + end + + describe "Agent.list/3 and create/3 / delete/3" do + test "lists agent tokens for the org", %{organization: org, ctx: ctx} do + _token = agent_token_fixture(org.id, "Foo") + assert {:ok, agents} = Resolvers.Agent.list(nil, %{}, ctx) + assert Enum.any?(agents, &(&1.name == "Foo")) + end + + test "creates a new agent token and returns the raw token", %{ctx: ctx} do + assert {:ok, %{token: raw, name: name}} = Resolvers.Agent.create(nil, %{name: "Bar"}, ctx) + assert is_binary(raw) and raw != "" + assert name == "Bar" + end + + test "returns 'Agent not found' for unknown id", %{ctx: ctx} do + assert {:error, "Agent not found"} = + Resolvers.Agent.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "delete on unknown id returns not-found error", %{ctx: ctx} do + assert {:error, "Agent not found"} = + Resolvers.Agent.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + end + + describe "Organization.list_mine/3 and get/3 and update/3" do + test "list_mine includes the user's org", %{user: user, organization: org} do + ctx = %{context: %{user: user}} + assert {:ok, orgs} = Resolvers.Organization.list_mine(nil, %{}, ctx) + assert Enum.any?(orgs, &(&1.id == org.id)) + end + + test "get/3 returns the org from context", %{ctx: ctx, organization: org} do + assert {:ok, fetched} = Resolvers.Organization.get(nil, %{}, ctx) + assert fetched.id == org.id + end + + test "update/3 with valid input changes the name", %{ctx: ctx} do + assert {:ok, updated} = Resolvers.Organization.update(nil, %{input: %{name: "Renamed Co"}}, ctx) + assert updated.name == "Renamed Co" + end + + test "update/3 with invalid input returns an error string", %{ctx: ctx} do + assert {:error, msg} = Resolvers.Organization.update(nil, %{input: %{name: "x"}}, ctx) + assert is_binary(msg) + end + end + + describe "Site resolvers" do + test "list/3 returns sites for org, get/3 returns one, create/3 succeeds, update/3 + delete/3 work", %{ + organization: org, + ctx: ctx + } do + device = device_fixture(%{organization_id: org.id}) + site_id = device.site_id + + assert {:ok, sites} = Resolvers.Site.list(nil, %{}, ctx) + assert Enum.any?(sites, &(&1.id == site_id)) + + assert {:ok, fetched} = Resolvers.Site.get(nil, %{id: site_id}, ctx) + assert fetched.id == site_id + + assert {:ok, created} = + Resolvers.Site.create(nil, %{input: %{name: "New Site"}}, ctx) + + assert created.name == "New Site" + + assert {:ok, updated} = + Resolvers.Site.update(nil, %{id: created.id, input: %{name: "Renamed"}}, ctx) + + assert updated.name == "Renamed" + + assert {:ok, %{success: true}} = Resolvers.Site.delete(nil, %{id: created.id}, ctx) + refute Repo.get(Towerops.Sites.Site, created.id) + end + + test "get/3 with unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Site not found"} = + Resolvers.Site.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "update/3 with unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Site not found"} = + Resolvers.Site.update( + nil, + %{id: Ecto.UUID.generate(), input: %{name: "x"}}, + ctx + ) + end + + test "delete/3 with unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Site not found"} = + Resolvers.Site.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + end + + describe "Integration resolvers" do + test "list/3 returns []", %{ctx: ctx} do + assert {:ok, []} = Resolvers.Integration.list(nil, %{}, ctx) + end + + test "update on unknown id returns 'Integration not found'", %{ctx: ctx} do + assert {:error, "Integration not found"} = + Resolvers.Integration.update( + nil, + %{id: Ecto.UUID.generate(), input: %{name: "x"}}, + ctx + ) + end + + test "delete on unknown id returns 'Integration not found'", %{ctx: ctx} do + assert {:error, "Integration not found"} = + Resolvers.Integration.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "test_connection on unknown id returns 'Integration not found'", %{ctx: ctx} do + assert {:error, "Integration not found"} = + Resolvers.Integration.test_connection(nil, %{id: Ecto.UUID.generate()}, ctx) + end + end + + describe "MaintenanceWindow resolvers" do + test "list/3 returns []", %{ctx: ctx} do + assert {:ok, []} = Resolvers.MaintenanceWindow.list(nil, %{}, ctx) + end + + test "list/3 honors filter args", %{ctx: ctx} do + for filter <- ["active", "upcoming", "past", "everything"] do + assert {:ok, _} = Resolvers.MaintenanceWindow.list(nil, %{filter: filter}, ctx) + end + end + + test "get/3 unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Maintenance window not found"} = + Resolvers.MaintenanceWindow.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "update on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Maintenance window not found"} = + Resolvers.MaintenanceWindow.update( + nil, + %{id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "delete on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Maintenance window not found"} = + Resolvers.MaintenanceWindow.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "create + get + update + delete happy path", %{ctx: ctx} do + starts = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) + ends = DateTime.add(starts, 7200, :second) + + assert {:ok, window} = + Resolvers.MaintenanceWindow.create( + nil, + %{ + input: %{ + name: "Smoke Window", + starts_at: starts, + ends_at: ends, + suppress_alerts: true + } + }, + ctx + ) + + assert {:ok, fetched} = Resolvers.MaintenanceWindow.get(nil, %{id: window.id}, ctx) + assert fetched.id == window.id + + assert {:ok, updated} = + Resolvers.MaintenanceWindow.update( + nil, + %{id: window.id, input: %{name: "Renamed"}}, + ctx + ) + + assert updated.name == "Renamed" + + assert {:ok, %{success: true}} = + Resolvers.MaintenanceWindow.delete(nil, %{id: window.id}, ctx) + end + + test "create with invalid input returns formatted error", %{ctx: ctx} do + assert {:error, msg} = + Resolvers.MaintenanceWindow.create(nil, %{input: %{}}, ctx) + + assert is_binary(msg) + end + end + + describe "Alert resolvers — happy paths" do + test "list returns inserted alerts; get/3 fetches by id", %{organization: org, ctx: ctx} do + device = device_fixture(%{organization_id: org.id}) + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second), + message: "down" + }) + + assert {:ok, alerts} = Resolvers.Alert.list(nil, %{}, ctx) + assert Enum.any?(alerts, &(&1.id == alert.id)) + + assert {:ok, fetched} = Resolvers.Alert.get(nil, %{id: alert.id}, ctx) + assert fetched.id == alert.id + end + + test "acknowledge transitions an alert and returns it", %{organization: org, ctx: ctx} do + device = device_fixture(%{organization_id: org.id}) + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + assert {:ok, acked} = Resolvers.Alert.acknowledge(nil, %{id: alert.id}, ctx) + assert acked.id == alert.id + assert acked.acknowledged_at + end + + test "resolve_alert transitions and returns it", %{organization: org, ctx: ctx} do + device = device_fixture(%{organization_id: org.id}) + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + assert {:ok, resolved} = Resolvers.Alert.resolve_alert(nil, %{id: alert.id}, ctx) + assert resolved.resolved_at + end + end + + describe "EscalationPolicy resolvers (not-found paths)" do + test "get on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Escalation policy not found"} = + Resolvers.EscalationPolicy.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "update on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Escalation policy not found"} = + Resolvers.EscalationPolicy.update( + nil, + %{id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "delete on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Escalation policy not found"} = + Resolvers.EscalationPolicy.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "create_rule with unknown policy returns not-found", %{ctx: ctx} do + assert {:error, "Escalation policy not found"} = + Resolvers.EscalationPolicy.create_rule( + nil, + %{escalation_policy_id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "update_rule on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Rule not found"} = + Resolvers.EscalationPolicy.update_rule( + nil, + %{id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "delete_rule on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Rule not found"} = + Resolvers.EscalationPolicy.delete_rule(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "delete_target on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Target not found"} = + Resolvers.EscalationPolicy.delete_target(nil, %{id: Ecto.UUID.generate()}, ctx) + end + end + + describe "Schedule resolvers (not-found paths)" do + test "get on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Schedule not found"} = + Resolvers.Schedule.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "on_call with unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Schedule not found"} = + Resolvers.Schedule.on_call(nil, %{schedule_id: Ecto.UUID.generate()}, ctx) + end + + test "update on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Schedule not found"} = + Resolvers.Schedule.update( + nil, + %{id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "delete on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Schedule not found"} = + Resolvers.Schedule.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "create_layer with unknown schedule returns not-found", %{ctx: ctx} do + assert {:error, "Schedule not found"} = + Resolvers.Schedule.create_layer( + nil, + %{schedule_id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "delete_layer on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Layer not found"} = + Resolvers.Schedule.delete_layer(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "remove_member on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Member not found"} = + Resolvers.Schedule.remove_member(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "create_override with unknown schedule returns not-found", %{ctx: ctx} do + assert {:error, "Schedule not found"} = + Resolvers.Schedule.create_override( + nil, + %{schedule_id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "delete_override on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Override not found"} = + Resolvers.Schedule.delete_override(nil, %{id: Ecto.UUID.generate()}, ctx) + end + end + + describe "Member resolvers (error paths)" do + test "remove on unknown user returns 'Member not found'", %{ctx: ctx} do + assert {:error, "Member not found"} = + Resolvers.Member.remove(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "update_role on unknown user returns 'Member not found'", %{ctx: ctx} do + assert {:error, "Member not found"} = + Resolvers.Member.update_role( + nil, + %{id: Ecto.UUID.generate(), role: "technician"}, + ctx + ) + end + + test "cancel_invitation on unknown id returns 'Invitation not found'", %{ctx: ctx} do + assert {:error, "Invitation not found"} = + Resolvers.Member.cancel_invitation(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "list/3 returns owner as a member", %{ctx: ctx, user: user} do + assert {:ok, members} = Resolvers.Member.list(nil, %{}, ctx) + assert Enum.any?(members, &(&1.user_id == user.id)) + end + end + + describe "Device resolvers" do + test "list/3 with no devices returns []", %{ctx: ctx} do + assert {:ok, []} = Resolvers.Device.list(nil, %{}, ctx) + end + + test "get/3 returns the device", %{organization: org, ctx: ctx} do + d = device_fixture(%{organization_id: org.id}) + assert {:ok, found} = Resolvers.Device.get(nil, %{id: d.id}, ctx) + assert found.id == d.id + end + + test "get on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Device not found"} = + Resolvers.Device.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "delete on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Device not found"} = + Resolvers.Device.delete(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "update on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Device not found"} = + Resolvers.Device.update( + nil, + %{id: Ecto.UUID.generate(), input: %{}}, + ctx + ) + end + + test "metrics on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Device not found"} = + Resolvers.Device.metrics(nil, %{device_id: Ecto.UUID.generate()}, ctx) + end + + test "interfaces on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Device not found"} = + Resolvers.Device.interfaces(nil, %{device_id: Ecto.UUID.generate()}, ctx) + end + + test "interfaces returns [] when device has no snmp_device", %{organization: org, ctx: ctx} do + d = device_fixture(%{organization_id: org.id}) + assert {:ok, []} = Resolvers.Device.interfaces(nil, %{device_id: d.id}, ctx) + end + + test "metrics returns [] when device has no checks", %{organization: org, ctx: ctx} do + d = device_fixture(%{organization_id: org.id}) + + assert {:ok, []} = + Resolvers.Device.metrics( + nil, + %{device_id: d.id, time_range: "12h", sensor_type: nil}, + ctx + ) + end + + test "metrics with malformed time_range falls back to 24h", %{organization: org, ctx: ctx} do + d = device_fixture(%{organization_id: org.id}) + + assert {:ok, []} = + Resolvers.Device.metrics( + nil, + %{device_id: d.id, time_range: "garbage"}, + ctx + ) + end + end + + describe "Alert resolvers" do + test "list/3 returns []", %{ctx: ctx} do + assert {:ok, []} = Resolvers.Alert.list(nil, %{}, ctx) + end + + test "get on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Alert not found"} = + Resolvers.Alert.get(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "acknowledge on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Alert not found"} = + Resolvers.Alert.acknowledge(nil, %{id: Ecto.UUID.generate()}, ctx) + end + + test "resolve_alert on unknown id returns not-found", %{ctx: ctx} do + assert {:error, "Alert not found"} = + Resolvers.Alert.resolve_alert(nil, %{id: Ecto.UUID.generate()}, ctx) + end + end +end diff --git a/test/towerops_web/graphql/resolvers/unauthenticated_test.exs b/test/towerops_web/graphql/resolvers/unauthenticated_test.exs new file mode 100644 index 00000000..4795adde --- /dev/null +++ b/test/towerops_web/graphql/resolvers/unauthenticated_test.exs @@ -0,0 +1,323 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.UnauthenticatedTest do + @moduledoc """ + Verifies that every public resolver function returns an authentication error + when invoked without an `organization_id` in the resolution context. + + Catches accidental drift where someone adds a new resolver clause that + accepts requests without auth. + """ + use ExUnit.Case, async: true + + alias ToweropsWeb.GraphQL.Resolvers + + @auth_error {:error, "Authentication required"} + @no_auth %{} + + describe "Activity" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Activity.list(nil, %{}, @no_auth) + end + end + + describe "Agent" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Agent.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.Agent.get(nil, %{id: "x"}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = Resolvers.Agent.create(nil, %{name: "x"}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.Agent.delete(nil, %{id: "x"}, @no_auth) + end + end + + describe "Alert" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Alert.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.Alert.get(nil, %{id: "x"}, @no_auth) + end + + test "acknowledge/3 unauthenticated" do + assert @auth_error = Resolvers.Alert.acknowledge(nil, %{id: "x"}, @no_auth) + end + + test "resolve_alert/3 unauthenticated" do + assert @auth_error = Resolvers.Alert.resolve_alert(nil, %{id: "x"}, @no_auth) + end + end + + describe "Device" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Device.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.Device.get(nil, %{id: "x"}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = Resolvers.Device.create(nil, %{input: %{}}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = Resolvers.Device.update(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.Device.delete(nil, %{id: "x"}, @no_auth) + end + + test "metrics/3 unauthenticated" do + assert @auth_error = Resolvers.Device.metrics(nil, %{device_id: "x"}, @no_auth) + end + + test "interfaces/3 unauthenticated" do + assert @auth_error = Resolvers.Device.interfaces(nil, %{device_id: "x"}, @no_auth) + end + end + + describe "EscalationPolicy" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.get(nil, %{id: "x"}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.create(nil, %{input: %{}}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.update(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.delete(nil, %{id: "x"}, @no_auth) + end + + test "create_rule/3 unauthenticated" do + assert @auth_error = + Resolvers.EscalationPolicy.create_rule( + nil, + %{escalation_policy_id: "x", input: %{}}, + @no_auth + ) + end + + test "update_rule/3 unauthenticated" do + assert @auth_error = + Resolvers.EscalationPolicy.update_rule(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete_rule/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.delete_rule(nil, %{id: "x"}, @no_auth) + end + + test "create_target/3 unauthenticated" do + assert @auth_error = + Resolvers.EscalationPolicy.create_target( + nil, + %{escalation_rule_id: "x", input: %{}}, + @no_auth + ) + end + + test "delete_target/3 unauthenticated" do + assert @auth_error = Resolvers.EscalationPolicy.delete_target(nil, %{id: "x"}, @no_auth) + end + end + + describe "Integration" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Integration.list(nil, %{}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = Resolvers.Integration.create(nil, %{input: %{}}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = + Resolvers.Integration.update(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.Integration.delete(nil, %{id: "x"}, @no_auth) + end + + test "test_connection/3 unauthenticated" do + assert @auth_error = Resolvers.Integration.test_connection(nil, %{id: "x"}, @no_auth) + end + end + + describe "MaintenanceWindow" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.MaintenanceWindow.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.MaintenanceWindow.get(nil, %{id: "x"}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = + Resolvers.MaintenanceWindow.create(nil, %{input: %{}}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = + Resolvers.MaintenanceWindow.update(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.MaintenanceWindow.delete(nil, %{id: "x"}, @no_auth) + end + end + + describe "Member" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Member.list(nil, %{}, @no_auth) + end + + test "invite/3 unauthenticated" do + assert @auth_error = Resolvers.Member.invite(nil, %{email: "x"}, @no_auth) + end + + test "cancel_invitation/3 unauthenticated" do + assert @auth_error = Resolvers.Member.cancel_invitation(nil, %{id: "x"}, @no_auth) + end + + test "remove/3 unauthenticated" do + assert @auth_error = Resolvers.Member.remove(nil, %{id: "x"}, @no_auth) + end + + test "update_role/3 unauthenticated" do + assert @auth_error = + Resolvers.Member.update_role(nil, %{id: "x", role: "admin"}, @no_auth) + end + end + + describe "Organization" do + test "list_mine/3 unauthenticated" do + assert @auth_error = Resolvers.Organization.list_mine(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.Organization.get(nil, %{}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = Resolvers.Organization.update(nil, %{input: %{}}, @no_auth) + end + end + + describe "Schedule" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.get(nil, %{id: "x"}, @no_auth) + end + + test "on_call/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.on_call(nil, %{schedule_id: "x"}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.create(nil, %{input: %{}}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = + Resolvers.Schedule.update(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.delete(nil, %{id: "x"}, @no_auth) + end + + test "create_layer/3 unauthenticated" do + assert @auth_error = + Resolvers.Schedule.create_layer( + nil, + %{schedule_id: "x", input: %{}}, + @no_auth + ) + end + + test "update_layer/3 unauthenticated" do + assert @auth_error = + Resolvers.Schedule.update_layer(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete_layer/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.delete_layer(nil, %{id: "x"}, @no_auth) + end + + test "add_member/3 unauthenticated" do + assert @auth_error = + Resolvers.Schedule.add_member( + nil, + %{layer_id: "x", user_id: "u", position: 1}, + @no_auth + ) + end + + test "remove_member/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.remove_member(nil, %{id: "x"}, @no_auth) + end + + test "create_override/3 unauthenticated" do + assert @auth_error = + Resolvers.Schedule.create_override( + nil, + %{schedule_id: "x", input: %{}}, + @no_auth + ) + end + + test "delete_override/3 unauthenticated" do + assert @auth_error = Resolvers.Schedule.delete_override(nil, %{id: "x"}, @no_auth) + end + end + + describe "Site" do + test "list/3 unauthenticated" do + assert @auth_error = Resolvers.Site.list(nil, %{}, @no_auth) + end + + test "get/3 unauthenticated" do + assert @auth_error = Resolvers.Site.get(nil, %{id: "x"}, @no_auth) + end + + test "create/3 unauthenticated" do + assert @auth_error = Resolvers.Site.create(nil, %{input: %{}}, @no_auth) + end + + test "update/3 unauthenticated" do + assert @auth_error = Resolvers.Site.update(nil, %{id: "x", input: %{}}, @no_auth) + end + + test "delete/3 unauthenticated" do + assert @auth_error = Resolvers.Site.delete(nil, %{id: "x"}, @no_auth) + end + end + + describe "Helpers" do + test "authentication_error/0 returns canonical tuple" do + assert Resolvers.Helpers.authentication_error() == @auth_error + end + end +end diff --git a/test/towerops_web/live/integrations_smoke_test.exs b/test/towerops_web/live/integrations_smoke_test.exs new file mode 100644 index 00000000..74370b3b --- /dev/null +++ b/test/towerops_web/live/integrations_smoke_test.exs @@ -0,0 +1,230 @@ +defmodule ToweropsWeb.IntegrationsSmokeTest do + @moduledoc """ + Smoke tests for low-coverage org-scoped integration LiveViews and device-scoped + LiveViews. These tests exercise the mount/render/handle_params paths without + asserting deep behavior, which is sufficient for coverage of happy-path code. + """ + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Towerops.Devices.MikrotikBackups + alias Towerops.MaintenanceFixtures + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Smoke Org"}, user.id) + %{organization: organization} + end + + defp assert_mount_or_redirect(result) do + assert match?({:ok, _, _}, result) or + match?({:error, {:redirect, _}}, result) or + match?({:error, {:live_redirect, _}}, result) + end + + describe "smoke: gaiia integration LiveViews" do + test "GET /orgs/:slug/settings/integrations/gaiia/mapping", %{conn: conn, organization: org} do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")) + end + + test "GET /orgs/:slug/settings/integrations/gaiia/mapping?tab=sites", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")) + end + + test "GET /orgs/:slug/settings/integrations/gaiia/mapping?filter=linked", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?filter=linked")) + end + + test "GET /orgs/:slug/settings/integrations/gaiia/mapping?filter=unlinked", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?filter=unlinked")) + end + + test "GET /orgs/:slug/settings/integrations/gaiia/reconciliation", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")) + end + + test "GET /orgs/:slug/settings/integrations/gaiia/reconciliation?tab=devices", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=devices")) + end + + test "GET /orgs/:slug/settings/integrations/gaiia/reconciliation?tab=sites", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=sites")) + end + end + + describe "smoke: preseem integration LiveViews" do + test "GET /orgs/:slug/settings/integrations/preseem/devices", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices")) + end + + test "GET /orgs/:slug/settings/integrations/preseem/devices?filter=matched", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=matched")) + end + + test "GET /orgs/:slug/settings/integrations/preseem/devices?filter=unmatched", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=unmatched")) + end + + test "GET /orgs/:slug/settings/integrations/preseem/insights", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")) + end + + test "GET /orgs/:slug/settings/integrations/preseem/insights?status=resolved", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?status=resolved")) + end + + test "GET /orgs/:slug/settings/integrations/preseem/insights?type=qoe&urgency=high", %{ + conn: conn, + organization: org + } do + assert_mount_or_redirect( + live( + conn, + ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?type=qoe&urgency=high" + ) + ) + end + end + + describe "smoke: user settings sub-tabs (SessionManager / TotpManager)" do + test "GET /users/settings (default index)", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings")) + end + + test "GET /users/settings?tab=sessions", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=sessions")) + end + + test "GET /users/settings?tab=security", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=security")) + end + + test "GET /users/settings?tab=personal", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=personal")) + end + + test "GET /users/settings?tab=account", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=account")) + end + + test "GET /users/settings?tab=notifications", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=notifications")) + end + + test "GET /users/settings?tab=api", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=api")) + end + end + + describe "smoke: device-scoped LiveViews (ConfigTimeline / Compare)" do + setup %{organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Site-#{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id + }) + + %{site: site, device: device} + end + + test "GET /devices/:device_id/config-timeline", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline")) + end + + test "GET /devices/:device_id/config-timeline?range=24h", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline?range=24h")) + end + + test "GET /devices/:device_id/config-timeline?range=30d", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline?range=30d")) + end + + test "GET /devices/:device_id/backups/compare without backup params redirects", %{ + conn: conn, + device: device + } do + # Compare requires backup_a + backup_b query params; without them it redirects. + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/backups/compare")) + end + + test "GET /devices/:device_id/backups/compare with two backups", %{ + conn: conn, + device: device + } do + {:ok, backup_a} = + MikrotikBackups.create_backup(device.id, "/system identity\nset name=router-a", "manual") + + {:ok, backup_b} = + MikrotikBackups.create_backup(device.id, "/system identity\nset name=router-b", "manual") + + assert_mount_or_redirect( + live( + conn, + ~p"/devices/#{device.id}/backups/compare?backup_a=#{backup_a.id}&backup_b=#{backup_b.id}" + ) + ) + end + end + + describe "smoke: maintenance show extra params" do + setup %{organization: org, user: user} do + window = + MaintenanceFixtures.maintenance_window_fixture(%{ + name: "Smoke Window 2", + organization_id: org.id, + created_by_id: user.id + }) + + %{window: window} + end + + test "GET /maintenance/:id with tab param", %{conn: conn, window: window} do + assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}?tab=devices")) + end + + test "GET /maintenance/:id/edit with section", %{conn: conn, window: window} do + assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}/edit")) + end + end +end diff --git a/test/towerops_web/live/smoke_test.exs b/test/towerops_web/live/smoke_test.exs index 7a9a58d2..8181afed 100644 --- a/test/towerops_web/live/smoke_test.exs +++ b/test/towerops_web/live/smoke_test.exs @@ -219,5 +219,132 @@ defmodule ToweropsWeb.LiveViewSmokeTest do test "GET /maintenance?filter=upcoming", %{conn: conn} do assert_mount_or_redirect(live(conn, ~p"/maintenance?filter=upcoming")) end + + test "GET /maintenance?filter=past", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/maintenance?filter=past")) + end + end + + describe "smoke: maintenance show & edit" do + setup %{organization: org, user: user} do + window = + Towerops.MaintenanceFixtures.maintenance_window_fixture(%{ + name: "Smoke Window", + organization_id: org.id, + created_by_id: user.id + }) + + %{window: window} + end + + test "GET /maintenance/:id", %{conn: conn, window: window} do + assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}")) + end + + test "GET /maintenance/:id/edit", %{conn: conn, window: window} do + assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}/edit")) + end + end + + describe "smoke: agents specific paths" do + setup %{organization: org} do + {:ok, agent_token, _raw} = + Towerops.Agents.create_agent_token(org.id, "Smoke Agent") + + %{agent_token: agent_token} + end + + test "GET /agents/:id", %{conn: conn, agent_token: token} do + assert_mount_or_redirect(live(conn, ~p"/agents/#{token.id}")) + end + + test "GET /agents/:id/edit", %{conn: conn, agent_token: token} do + assert_mount_or_redirect(live(conn, ~p"/agents/#{token.id}/edit")) + end + end + + describe "smoke: org-scoped settings" do + test "GET /orgs/:slug/settings", %{conn: conn, organization: org} do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings")) + end + + test "GET /orgs/:slug/settings/integrations", %{conn: conn, organization: org} do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations")) + end + end + + describe "smoke: more top-level pages" do + test "GET /agents", %{conn: conn} do + assert {:ok, _, _} = live(conn, ~p"/agents") + end + + test "GET /sites", %{conn: conn} do + assert {:ok, _, _} = live(conn, ~p"/sites") + end + + test "GET /devices", %{conn: conn} do + assert {:ok, _, _} = live(conn, ~p"/devices") + end + + test "GET /mobile/qr-login", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/mobile/qr-login")) + end + + test "GET /account/activity", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/account/activity")) + end + + test "GET /account/totp-enrollment", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/account/totp-enrollment")) + end + end + + describe "smoke: device-related deep paths" do + setup %{organization: org} do + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + name: "Smoke Device" + }) + + %{device: device} + end + + test "GET /devices/:id", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}")) + end + + test "GET /devices/:id/edit", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/edit")) + end + + test "GET /devices/:id/config-timeline", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline")) + end + + test "GET /devices/:id/graph/temperature", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/graph/temperature")) + end + end + + describe "smoke: schedules deep paths" do + setup %{organization: org} do + {:ok, schedule} = + Towerops.OnCall.create_schedule(%{ + name: "Smoke Schedule", + organization_id: org.id, + timezone: "America/Chicago" + }) + + %{schedule: schedule} + end + + test "GET /schedules/:id", %{conn: conn, schedule: schedule} do + assert_mount_or_redirect(live(conn, ~p"/schedules/#{schedule.id}")) + end + + test "GET /schedules/:id/edit", %{conn: conn, schedule: schedule} do + assert_mount_or_redirect(live(conn, ~p"/schedules/#{schedule.id}/edit")) + end end end