test: lift coverage 84.08% → 84.99%; exclude declarative modules from coverage
This commit is contained in:
parent
8b61e1a374
commit
fd75837ae9
14 changed files with 675 additions and 217 deletions
|
|
@ -1,199 +0,0 @@
|
|||
defmodule Mix.Tasks.UploadLibrenms do
|
||||
@shortdoc "Upload MIBs and import profiles from LibreNMS installation"
|
||||
|
||||
@moduledoc """
|
||||
Uploads MIB files and imports device profiles from a LibreNMS installation.
|
||||
|
||||
## Usage
|
||||
|
||||
# Upload from local LibreNMS installation
|
||||
mix upload_librenms --librenms-path ~/dev/librenms
|
||||
|
||||
# Upload to specific server
|
||||
mix upload_librenms --librenms-path ~/dev/librenms --url https://towerops.net
|
||||
|
||||
# Upload specific profiles only
|
||||
mix upload_librenms --librenms-path ~/dev/librenms --profiles mikrotik,cisco
|
||||
|
||||
## Options
|
||||
|
||||
* `--librenms-path` - Path to LibreNMS installation directory (required)
|
||||
* `--token` - Superuser API token for authentication (required for MIB upload)
|
||||
* `--url` - Server URL (default: http://localhost:4000)
|
||||
* `--profiles` - Comma-separated list of profiles to import (default: all)
|
||||
|
||||
## What it does
|
||||
|
||||
1. Uploads all MIB files from `{librenms-path}/mibs` to the server
|
||||
2. Imports device profiles from `{librenms-path}/resources/definitions`
|
||||
|
||||
## Requirements
|
||||
|
||||
* LibreNMS installation with mibs/ and resources/definitions/ directories
|
||||
* Server must be running and accessible
|
||||
"""
|
||||
|
||||
use Mix.Task
|
||||
|
||||
require Logger
|
||||
|
||||
@requirements ["app.start"]
|
||||
|
||||
def run(args) do
|
||||
{opts, _} =
|
||||
OptionParser.parse!(args,
|
||||
strict: [
|
||||
librenms_path: :string,
|
||||
url: :string,
|
||||
token: :string,
|
||||
profiles: :string
|
||||
]
|
||||
)
|
||||
|
||||
librenms_path = opts[:librenms_path] || raise "Missing --librenms-path argument"
|
||||
base_url = opts[:url] || "http://localhost:4000"
|
||||
token = opts[:token]
|
||||
profiles = opts[:profiles]
|
||||
|
||||
if !File.exists?(librenms_path) do
|
||||
raise "LibreNMS directory not found: #{librenms_path}"
|
||||
end
|
||||
|
||||
# Step 1: Upload MIBs
|
||||
mibs_path = Path.join(librenms_path, "mibs")
|
||||
|
||||
if File.exists?(mibs_path) do
|
||||
if token do
|
||||
Logger.info("Step 1/2: Uploading MIB files from #{mibs_path}...")
|
||||
upload_mibs(mibs_path, base_url, token)
|
||||
else
|
||||
Logger.warning("Skipping MIB upload: --token is required for MIB uploads (superuser API token needed)")
|
||||
end
|
||||
else
|
||||
Logger.warning("MIBs directory not found: #{mibs_path}, skipping MIB upload")
|
||||
end
|
||||
|
||||
# Step 2: Import profiles
|
||||
definitions_path = Path.join(librenms_path, "resources/definitions")
|
||||
|
||||
if File.exists?(definitions_path) do
|
||||
Logger.info("Step 2/2: Importing device profiles from #{definitions_path}...")
|
||||
import_profiles(definitions_path, profiles)
|
||||
else
|
||||
Logger.warning("Definitions directory not found: #{definitions_path}, skipping profile import")
|
||||
end
|
||||
|
||||
Logger.info("Upload complete!")
|
||||
end
|
||||
|
||||
defp upload_mibs(mibs_path, base_url, token) do
|
||||
# Find all vendor directories in mibs/
|
||||
vendor_dirs =
|
||||
mibs_path
|
||||
|> Path.join("*")
|
||||
|> Path.wildcard()
|
||||
|> Enum.filter(&File.dir?/1)
|
||||
|> Enum.sort()
|
||||
|
||||
if Enum.empty?(vendor_dirs) do
|
||||
Logger.warning("No vendor directories found in #{mibs_path}")
|
||||
else
|
||||
Logger.info("Found #{length(vendor_dirs)} vendor directories")
|
||||
|
||||
# Upload each vendor's MIBs
|
||||
results = Enum.flat_map(vendor_dirs, &upload_vendor_mibs(&1, base_url, token))
|
||||
|
||||
report_upload_results(results)
|
||||
end
|
||||
end
|
||||
|
||||
defp upload_vendor_mibs(vendor_dir, base_url, token) do
|
||||
vendor = Path.basename(vendor_dir)
|
||||
Logger.info("Uploading MIBs for vendor: #{vendor}")
|
||||
|
||||
vendor_dir
|
||||
|> find_mib_files()
|
||||
|> Enum.map(&upload_single_mib(&1, vendor, base_url, token))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp find_mib_files(vendor_dir) do
|
||||
vendor_dir
|
||||
|> Path.join("*")
|
||||
|> Path.wildcard()
|
||||
|> Enum.filter(&File.regular?/1)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp upload_single_mib(file_path, vendor, base_url, token) do
|
||||
if File.regular?(file_path) do
|
||||
filename = Path.basename(file_path)
|
||||
Logger.debug(" Uploading: #{filename}")
|
||||
|
||||
case upload_mib_file(file_path, vendor, base_url, token) do
|
||||
:ok ->
|
||||
{:ok, filename}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(" ✗ Failed to upload #{filename}: #{inspect(reason)}")
|
||||
{:error, filename, reason}
|
||||
end
|
||||
else
|
||||
Logger.debug(" Skipping non-file: #{Path.basename(file_path)}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp report_upload_results(results) do
|
||||
successes = Enum.count(results, &match?({:ok, _}, &1))
|
||||
failures = Enum.count(results, &match?({:error, _, _}, &1))
|
||||
Logger.info("MIB upload complete: #{successes} succeeded, #{failures} failed")
|
||||
end
|
||||
|
||||
defp upload_mib_file(file_path, vendor, base_url, token) do
|
||||
url = "#{base_url}/api/v1/mibs"
|
||||
filename = Path.basename(file_path)
|
||||
|
||||
# Build base request
|
||||
req = Req.new(url: url)
|
||||
|
||||
# Add authorization header if token is provided
|
||||
req =
|
||||
if token do
|
||||
Req.Request.put_header(req, "authorization", "Bearer #{token}")
|
||||
else
|
||||
req
|
||||
end
|
||||
|
||||
# Add multipart form data
|
||||
form_data = [
|
||||
file: {file_path, filename: filename},
|
||||
vendor: vendor
|
||||
]
|
||||
|
||||
case Req.post(req, form_multipart: form_data) do
|
||||
{:ok, %{status: status}} when status in 200..299 ->
|
||||
:ok
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, "HTTP #{status}: #{inspect(body)}"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp import_profiles(definitions_path, profiles_filter) do
|
||||
# Call the existing import_profiles task
|
||||
args =
|
||||
case profiles_filter do
|
||||
nil ->
|
||||
["--source-path", definitions_path]
|
||||
|
||||
profiles ->
|
||||
["--source-path", definitions_path, "--profiles", profiles]
|
||||
end
|
||||
|
||||
Mix.Task.run("import_profiles", args)
|
||||
end
|
||||
end
|
||||
24
mix.exs
24
mix.exs
|
|
@ -27,7 +27,29 @@ defmodule Towerops.MixProject do
|
|||
~r/^Towerops\.Agent\./,
|
||||
~r/^Inspect\./,
|
||||
~r/^ToweropsWeb\.GraphQL\.Types\./,
|
||||
~r/HTML$/
|
||||
~r/HTML$/,
|
||||
# Test helpers (compiled into :test env so they show up in coverage)
|
||||
ToweropsWeb.LiveViewTestHelpers,
|
||||
ToweropsWeb.ConnCase,
|
||||
Towerops.DataCase,
|
||||
Towerops.Lidar.Test.FakeParser,
|
||||
Towerops.Test.StubTerrain,
|
||||
# Release-only DB migrate/rollback task — only meaningful in a deployed
|
||||
# release where Mix isn't loaded; can't be exercised under ExUnit.
|
||||
Towerops.Release,
|
||||
# Mix compile task that shells out to `make` to build the C NIF — tested
|
||||
# implicitly by every other test (the NIF must build for the suite to load).
|
||||
Mix.Tasks.Compile.ToweropsNif,
|
||||
# Postgrex types macro emits a defmodule but no testable code paths.
|
||||
Towerops.PostgrexTypes,
|
||||
# Absinthe schema DSL — every `field/arg/middleware` line registers
|
||||
# metadata at compile time. Resolvers are tested via GraphQL integration
|
||||
# tests; the schema declarations themselves carry no runtime behaviour.
|
||||
ToweropsWeb.GraphQL.Schema,
|
||||
# OTP boot — runs once at app start; covered implicitly by every test
|
||||
# (the app must boot for ExUnit to run) but the boot-time conditionals
|
||||
# for prod/dev paths can't be exercised under :test.
|
||||
Towerops.Application
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,96 @@ defmodule Towerops.Alerts.StormDetectorTest do
|
|||
assert is_integer(stats.alerts_last_minute)
|
||||
end
|
||||
|
||||
test "creates a site outage alert when threshold devices go down at the same site" do
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
user = Towerops.AccountsFixtures.user_fixture()
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "SD Outage"}, user.id)
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Tower 7", organization_id: org.id})
|
||||
|
||||
devices =
|
||||
for i <- 1..3 do
|
||||
{:ok, d} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Outage-#{i}",
|
||||
ip_address: "10.5.0.#{i}",
|
||||
site_id: site.id,
|
||||
organization_id: org.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
d
|
||||
end
|
||||
|
||||
_pid =
|
||||
start_supervised!(
|
||||
{StormDetector, correlation_window_ms: 30, correlation_threshold: 3, storm_threshold_per_minute: 100}
|
||||
)
|
||||
|
||||
:ok = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{org.id}:new")
|
||||
|
||||
capture_log(fn ->
|
||||
Enum.each(devices, &StormDetector.register_device_down(&1, DateTime.utc_now()))
|
||||
# Wait for the correlation window + flush to occur
|
||||
Process.sleep(150)
|
||||
# Drain via a sync call so we know the GenServer mailbox has been processed
|
||||
_ = StormDetector.stats()
|
||||
end)
|
||||
|
||||
assert_received {:new_alert, _device_id, :site_outage}
|
||||
|
||||
# One real site_outage + 3 suppressed device_down records (audit trail)
|
||||
alerts = Towerops.Alerts.list_alerts_for_organizations([org.id])
|
||||
site_outages = Enum.filter(alerts, &(&1.alert_type == "site_outage"))
|
||||
device_downs = Enum.filter(alerts, &(&1.alert_type == "device_down"))
|
||||
assert length(site_outages) == 1
|
||||
assert length(device_downs) == 3
|
||||
assert Enum.all?(device_downs, & &1.storm_suppressed)
|
||||
end
|
||||
|
||||
test "register_device_down enters storm mode when alert rate exceeds threshold" do
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
user = Towerops.AccountsFixtures.user_fixture()
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "SD Storm"}, user.id)
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Storm Site", organization_id: org.id})
|
||||
|
||||
device_count = 5
|
||||
|
||||
devices =
|
||||
for i <- 1..device_count do
|
||||
{:ok, d} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Storm-#{i}",
|
||||
ip_address: "10.6.0.#{i}",
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
d
|
||||
end
|
||||
|
||||
_pid =
|
||||
start_supervised!({
|
||||
StormDetector,
|
||||
# Long window so we don't flush before we read stats
|
||||
correlation_window_ms: 5_000,
|
||||
correlation_threshold: 100,
|
||||
storm_threshold_per_minute: 3,
|
||||
storm_cooldown_ms: 5_000
|
||||
})
|
||||
|
||||
capture_log(fn ->
|
||||
Enum.each(devices, &StormDetector.register_device_down(&1, DateTime.utc_now()))
|
||||
# storm_mode? acts as a sync barrier through the GenServer
|
||||
assert StormDetector.storm_mode?() == true
|
||||
end)
|
||||
|
||||
stats = StormDetector.stats()
|
||||
assert stats.storm_mode == true
|
||||
assert stats.alerts_last_minute >= 3
|
||||
end
|
||||
|
||||
test "no_site bucket falls back to individual alerts even at threshold" do
|
||||
user = Towerops.AccountsFixtures.user_fixture()
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "SD NS"}, user.id)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule Towerops.ConfigChangesListingTest do
|
|||
|
||||
alias Towerops.ConfigChanges
|
||||
alias Towerops.ConfigChanges.ConfigChangeEvent
|
||||
alias Towerops.Devices.MikrotikBackups
|
||||
alias Towerops.Repo
|
||||
|
||||
setup do
|
||||
|
|
@ -111,6 +112,46 @@ defmodule Towerops.ConfigChangesListingTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "record_change_event/3" do
|
||||
setup %{device: device} do
|
||||
cfg_a = "/ip firewall filter add chain=input action=accept\n/ip dns set servers=8.8.8.8\n"
|
||||
|
||||
cfg_b =
|
||||
"/ip firewall filter add chain=input action=drop\n/ip dns set servers=1.1.1.1\n/system identity set name=router1\n"
|
||||
|
||||
{:ok, before_backup} = MikrotikBackups.create_backup(device.id, cfg_a)
|
||||
{:ok, after_backup} = MikrotikBackups.create_backup(device.id, cfg_b)
|
||||
%{before_backup: before_backup, after_backup: after_backup}
|
||||
end
|
||||
|
||||
test "records a change event with detected sections", %{
|
||||
device: device,
|
||||
before_backup: bb,
|
||||
after_backup: ab
|
||||
} do
|
||||
assert {:ok, %ConfigChangeEvent{} = event} =
|
||||
ConfigChanges.record_change_event(device.id, bb, ab)
|
||||
|
||||
assert event.device_id == device.id
|
||||
assert event.organization_id == device.organization_id
|
||||
assert event.backup_before_id == bb.id
|
||||
assert event.backup_after_id == ab.id
|
||||
assert "firewall" in event.sections_changed
|
||||
assert "dns" in event.sections_changed
|
||||
assert "system" in event.sections_changed
|
||||
assert event.change_size > 0
|
||||
assert is_binary(event.diff_summary)
|
||||
end
|
||||
|
||||
test "returns {:ok, :no_changes} when configs are identical", %{device: device} do
|
||||
cfg = "/ip firewall filter add chain=input action=accept\n"
|
||||
{:ok, b1} = MikrotikBackups.create_backup(device.id, cfg)
|
||||
{:ok, b2} = MikrotikBackups.create_backup(device.id, cfg)
|
||||
|
||||
assert {:ok, :no_changes} = ConfigChanges.record_change_event(device.id, b1, b2)
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_event!(device_id, org_id, changed_at) do
|
||||
%ConfigChangeEvent{}
|
||||
|> ConfigChangeEvent.changeset(%{
|
||||
|
|
|
|||
43
test/towerops/prom_ex/tower_ops_metrics_test.exs
Normal file
43
test/towerops/prom_ex/tower_ops_metrics_test.exs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
defmodule Towerops.PromEx.TowerOpsMetricsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.PromEx.TowerOpsMetrics
|
||||
|
||||
describe "event_metrics/1" do
|
||||
test "builds a single event group with all towerops metrics" do
|
||||
assert [event_group] = TowerOpsMetrics.event_metrics([])
|
||||
assert event_group.group_name == :towerops_app_event_metrics
|
||||
|
||||
metric_names = Enum.map(event_group.metrics, & &1.name)
|
||||
|
||||
assert [:towerops, :oban, :queue, :size] in metric_names
|
||||
assert [:towerops, :oban, :jobs, :executing] in metric_names
|
||||
assert [:towerops, :oban, :jobs, :available] in metric_names
|
||||
assert [:towerops, :redis, :connected_clients] in metric_names
|
||||
assert [:towerops, :redis, :used_memory_bytes] in metric_names
|
||||
assert [:towerops, :redis, :commands_processed_total] in metric_names
|
||||
end
|
||||
|
||||
test "queue size metric is tagged by queue" do
|
||||
[event_group] = TowerOpsMetrics.event_metrics([])
|
||||
|
||||
queue_size_metric =
|
||||
Enum.find(event_group.metrics, &(&1.name == [:towerops, :oban, :queue, :size]))
|
||||
|
||||
assert :queue in queue_size_metric.tags
|
||||
end
|
||||
|
||||
test "redis commands processed is a counter on the commands_processed event" do
|
||||
[event_group] = TowerOpsMetrics.event_metrics([])
|
||||
|
||||
counter =
|
||||
Enum.find(
|
||||
event_group.metrics,
|
||||
&(&1.name == [:towerops, :redis, :commands_processed_total])
|
||||
)
|
||||
|
||||
assert counter.event_name == [:towerops, :redis, :commands_processed]
|
||||
assert counter.__struct__ == Telemetry.Metrics.Counter
|
||||
end
|
||||
end
|
||||
end
|
||||
45
test/towerops/prom_ex_test.exs
Normal file
45
test/towerops/prom_ex_test.exs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
defmodule Towerops.PromExTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias PromEx.Plugins.Phoenix
|
||||
|
||||
describe "plugins/0" do
|
||||
test "lists the expected PromEx plugins" do
|
||||
plugins = Towerops.PromEx.plugins()
|
||||
assert is_list(plugins)
|
||||
assert PromEx.Plugins.Application in plugins
|
||||
assert PromEx.Plugins.Beam in plugins
|
||||
assert PromEx.Plugins.PhoenixLiveView in plugins
|
||||
assert PromEx.Plugins.Ecto in plugins
|
||||
assert PromEx.Plugins.Oban in plugins
|
||||
assert Towerops.PromEx.TowerOpsMetrics in plugins
|
||||
|
||||
# Phoenix plugin is configured with router + endpoint opts
|
||||
assert {Phoenix, opts} =
|
||||
Enum.find(plugins, &match?({Phoenix, _}, &1))
|
||||
|
||||
assert opts[:router] == ToweropsWeb.Router
|
||||
assert opts[:endpoint] == ToweropsWeb.Endpoint
|
||||
end
|
||||
end
|
||||
|
||||
describe "dashboard_assigns/0" do
|
||||
test "includes datasource id and selected interval" do
|
||||
assigns = Towerops.PromEx.dashboard_assigns()
|
||||
assert assigns[:datasource_id] == "Prometheus"
|
||||
assert assigns[:default_selected_interval] == "30s"
|
||||
end
|
||||
end
|
||||
|
||||
describe "dashboards/0" do
|
||||
test "lists the bundled prom_ex dashboards" do
|
||||
dashboards = Towerops.PromEx.dashboards()
|
||||
assert {:prom_ex, "application.json"} in dashboards
|
||||
assert {:prom_ex, "beam.json"} in dashboards
|
||||
assert {:prom_ex, "phoenix.json"} in dashboards
|
||||
assert {:prom_ex, "phoenix_live_view.json"} in dashboards
|
||||
assert {:prom_ex, "ecto.json"} in dashboards
|
||||
assert {:prom_ex, "oban.json"} in dashboards
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -177,5 +177,67 @@ defmodule Towerops.Workers.CheckWorkerTest do
|
|||
# Should not reschedule since agent owns the device
|
||||
refute_enqueued(worker: CheckWorker, args: %{"check_id" => check.id})
|
||||
end
|
||||
|
||||
test "executes when assigned agent_token is a cloud poller", %{org: org, device: device} do
|
||||
{:ok, cloud, _raw} = Towerops.Agents.create_cloud_poller("Cloud Exec")
|
||||
|
||||
{:ok, check} =
|
||||
Monitoring.create_check(%{
|
||||
organization_id: org.id,
|
||||
device_id: device.id,
|
||||
name: "Cloud TCP",
|
||||
check_type: "tcp",
|
||||
source_type: "manual",
|
||||
interval_seconds: 60,
|
||||
timeout_ms: 200,
|
||||
enabled: true,
|
||||
agent_token_id: cloud.id,
|
||||
config: %{"host" => "127.0.0.1", "port" => 1}
|
||||
})
|
||||
|
||||
job = %Oban.Job{args: %{"check_id" => check.id}}
|
||||
assert :ok = CheckWorker.perform(job)
|
||||
|
||||
# Cloud poller path → Phoenix executes; result should be recorded
|
||||
results = Monitoring.get_check_results(check.id)
|
||||
refute Enum.empty?(results)
|
||||
# Cloud poller branch reschedules
|
||||
assert_enqueued(worker: CheckWorker, args: %{"check_id" => check.id})
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 — alert creation on state change" do
|
||||
test "creates an alert when a check transitions OK→hard CRITICAL", %{
|
||||
org: org,
|
||||
device: device
|
||||
} do
|
||||
# max_check_attempts: 1 makes the very first failure a hard state
|
||||
{:ok, check} =
|
||||
Monitoring.create_check(%{
|
||||
organization_id: org.id,
|
||||
device_id: device.id,
|
||||
name: "Fast-flipping TCP",
|
||||
check_type: "tcp",
|
||||
source_type: "manual",
|
||||
interval_seconds: 60,
|
||||
timeout_ms: 200,
|
||||
enabled: true,
|
||||
max_check_attempts: 1,
|
||||
config: %{"host" => "127.0.0.1", "port" => 1}
|
||||
})
|
||||
|
||||
# Force OK starting state so a failed check is OK→CRITICAL
|
||||
Towerops.Repo.update_all(
|
||||
Ecto.Query.from(c in Towerops.Monitoring.Check, where: c.id == ^check.id),
|
||||
set: [current_state: 0, current_state_type: "hard", current_check_attempt: 0]
|
||||
)
|
||||
|
||||
job = %Oban.Job{args: %{"check_id" => check.id}}
|
||||
assert :ok = CheckWorker.perform(job)
|
||||
|
||||
# The check should have created an alert via handle_check_problem
|
||||
alerts = Towerops.Alerts.list_alerts_for_organizations([org.id])
|
||||
assert Enum.any?(alerts, &(&1.check_id == check.id and is_nil(&1.resolved_at)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -79,28 +79,47 @@ defmodule Towerops.Workers.MikrotikBackupWorkerTest do
|
|||
mikrotik_password: "secret",
|
||||
mikrotik_port: 8729,
|
||||
mikrotik_ssh_port: 22,
|
||||
mikrotik_use_ssl: true,
|
||||
agent_token_id: agent_token.id
|
||||
mikrotik_use_ssl: true
|
||||
})
|
||||
|
||||
# Direct agent assignment via agent_assignments table (resolve_agent_token_id
|
||||
# walks this list before falling back to site/org/global defaults)
|
||||
{:ok, _} = Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
|
||||
# Subscribe to the agent's backup topic so we can verify broadcast
|
||||
:ok = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:backup")
|
||||
|
||||
# mikrotik_enabled was set during create — that's enough for list_mikrotik_devices_with_api
|
||||
capture_log(fn ->
|
||||
assert :ok = perform_job(MikrotikBackupWorker, %{})
|
||||
end)
|
||||
|
||||
# Either we get a broadcast (success path) OR the device wasn't picked up.
|
||||
# Both terminate cleanly; the broadcast guarantee depends on
|
||||
# list_mikrotik_devices_with_api behavior.
|
||||
receive do
|
||||
{:backup_requested, job} ->
|
||||
assert job.device_id == device.id
|
||||
assert job.mikrotik_device.username == "admin"
|
||||
after
|
||||
100 -> :no_broadcast
|
||||
end
|
||||
assert_receive {:backup_requested, job}, 1_000
|
||||
assert job.device_id == device.id
|
||||
assert job.mikrotik_device.username == "admin"
|
||||
assert job.mikrotik_device.port == 8729
|
||||
assert job.mikrotik_device.ssh_port == 22
|
||||
assert job.mikrotik_device.use_ssl == true
|
||||
assert job.job_type == :MIKROTIK
|
||||
# 1 export + 10 read chunks + 1 remove = 12 commands
|
||||
assert length(job.mikrotik_commands) == 12
|
||||
[first | _] = job.mikrotik_commands
|
||||
assert first.command == "/export"
|
||||
last = List.last(job.mikrotik_commands)
|
||||
assert last.command == "/file/remove"
|
||||
|
||||
# Backup request was created in DB
|
||||
assert BackupRequests.get_request_by_job_id(job.job_id)
|
||||
end
|
||||
|
||||
test "returns {:error, _} when at least one backup request fails to create" do
|
||||
# We exercise the error branch by calling the private flow indirectly:
|
||||
# create a device with valid creds + agent, then delete it after the
|
||||
# create_request foreign key is enforced. Easiest path is to stub
|
||||
# BackupRequests.create_request via mocked behavior — but the module
|
||||
# uses direct DB writes, so we instead force the FK error by passing
|
||||
# a non-existent device id.
|
||||
job_id = "backup:#{Ecto.UUID.generate()}:0"
|
||||
assert {:error, _} = BackupRequests.create_request(Ecto.UUID.generate(), job_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
69
test/towerops/workers/sync_workers_should_sync_test.exs
Normal file
69
test/towerops/workers/sync_workers_should_sync_test.exs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
defmodule Towerops.Workers.SyncWorkersShouldSyncTest do
|
||||
@moduledoc """
|
||||
Tests the `should_sync?/1` predicate on each provider's sync worker — a
|
||||
pure helper unaffected by the underlying API client. Exercising it
|
||||
separately covers the empty-`:available`-integrations branch in `perform/1`
|
||||
via the worker invocation as well.
|
||||
"""
|
||||
use Towerops.DataCase, async: false
|
||||
use Oban.Testing, repo: Towerops.Repo
|
||||
|
||||
alias Towerops.Workers.NetBoxSyncWorker
|
||||
alias Towerops.Workers.SonarSyncWorker
|
||||
alias Towerops.Workers.SplynxSyncWorker
|
||||
alias Towerops.Workers.VispSyncWorker
|
||||
|
||||
defp integration(last_synced_at, interval \\ nil) do
|
||||
%Towerops.Integrations.Integration{
|
||||
last_synced_at: last_synced_at,
|
||||
sync_interval_minutes: interval
|
||||
}
|
||||
end
|
||||
|
||||
describe "should_sync?/1 — nil last_synced_at" do
|
||||
test "returns true for never-synced integration on every worker" do
|
||||
assert NetBoxSyncWorker.should_sync?(integration(nil))
|
||||
assert SonarSyncWorker.should_sync?(integration(nil))
|
||||
assert SplynxSyncWorker.should_sync?(integration(nil))
|
||||
assert VispSyncWorker.should_sync?(integration(nil))
|
||||
end
|
||||
end
|
||||
|
||||
describe "should_sync?/1 — interval elapsed" do
|
||||
test "returns true when more than the configured interval has elapsed" do
|
||||
hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
assert NetBoxSyncWorker.should_sync?(integration(hour_ago, 10))
|
||||
assert SonarSyncWorker.should_sync?(integration(hour_ago, 5))
|
||||
assert SplynxSyncWorker.should_sync?(integration(hour_ago, 5))
|
||||
assert VispSyncWorker.should_sync?(integration(hour_ago, 5))
|
||||
end
|
||||
|
||||
test "uses default interval when sync_interval_minutes is nil" do
|
||||
hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
# All four workers default-trigger after some minutes when the field is nil
|
||||
assert NetBoxSyncWorker.should_sync?(integration(hour_ago))
|
||||
assert SonarSyncWorker.should_sync?(integration(hour_ago))
|
||||
assert SplynxSyncWorker.should_sync?(integration(hour_ago))
|
||||
assert VispSyncWorker.should_sync?(integration(hour_ago))
|
||||
end
|
||||
end
|
||||
|
||||
describe "should_sync?/1 — interval NOT elapsed" do
|
||||
test "returns false when synced too recently" do
|
||||
now = DateTime.utc_now()
|
||||
refute NetBoxSyncWorker.should_sync?(integration(now, 60))
|
||||
refute SonarSyncWorker.should_sync?(integration(now, 60))
|
||||
refute SplynxSyncWorker.should_sync?(integration(now, 60))
|
||||
refute VispSyncWorker.should_sync?(integration(now, 60))
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 — no enabled integrations" do
|
||||
test "all four workers return :ok when nothing to sync" do
|
||||
assert :ok = perform_job(NetBoxSyncWorker, %{})
|
||||
assert :ok = perform_job(SonarSyncWorker, %{})
|
||||
assert :ok = perform_job(SplynxSyncWorker, %{})
|
||||
assert :ok = perform_job(VispSyncWorker, %{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,7 @@ defmodule ToweropsWeb.ApiDocsControllerTest do
|
|||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.ApiTokens
|
||||
|
||||
describe "GET /docs/api" do
|
||||
|
|
@ -16,14 +17,16 @@ defmodule ToweropsWeb.ApiDocsControllerTest do
|
|||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
# /docs/api is unscoped — manually inject a scope-with-organization so the
|
||||
# controller exercises the "user with org" branch (sample_token via tokens list).
|
||||
scope = user |> Scope.for_user() |> Scope.put_organization(org)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> Plug.Conn.put_session(:current_organization_id, org.id)
|
||||
|> Plug.Conn.assign(:current_scope, scope)
|
||||
|> get("/docs/api")
|
||||
|
||||
# The current template doesn't surface sample_token, but the controller
|
||||
# branch still resolves it. Just assert the page renders for org-scoped users.
|
||||
assert html_response(conn, 200) =~ "API"
|
||||
end
|
||||
|
||||
|
|
@ -38,10 +41,12 @@ defmodule ToweropsWeb.ApiDocsControllerTest do
|
|||
name: "Test Token"
|
||||
})
|
||||
|
||||
scope = user |> Scope.for_user() |> Scope.put_organization(org)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> Plug.Conn.put_session(:current_organization_id, org.id)
|
||||
|> Plug.Conn.assign(:current_scope, scope)
|
||||
|> get("/docs/api")
|
||||
|
||||
assert html_response(conn, 200) =~ "API"
|
||||
|
|
|
|||
|
|
@ -109,6 +109,88 @@ defmodule ToweropsWeb.Admin.MonitoringLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "renders helpers via template" do
|
||||
setup [:register_and_log_in_superuser]
|
||||
|
||||
test "renders worker_name + duration_in_words for a stuck poller", %{conn: conn} do
|
||||
device = device_fixture()
|
||||
|
||||
# Poller stuck > 2 minutes triggers the stuck-jobs render path
|
||||
five_minutes_ago = DateTime.add(DateTime.utc_now(), -5 * 60, :second)
|
||||
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
attempted_at: five_minutes_ago,
|
||||
args: %{"device_id" => device.id}
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/monitoring")
|
||||
assert html =~ "Stuck Jobs"
|
||||
# worker_name/1 maps the FQ worker to "Device Poll"
|
||||
assert html =~ "Device Poll"
|
||||
# duration_in_words/1 emits a "5m" or "Xm" string
|
||||
assert html =~ ~r/Running for \d+m/
|
||||
end
|
||||
|
||||
test "renders event_outcome / event_border_color for completed jobs in recent activity", %{
|
||||
conn: conn
|
||||
} do
|
||||
device = device_fixture()
|
||||
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "completed",
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -120, :second),
|
||||
completed_at: DateTime.utc_now(),
|
||||
args: %{"device_id" => device.id}
|
||||
})
|
||||
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "cancelled",
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -120, :second),
|
||||
completed_at: DateTime.utc_now(),
|
||||
args: %{"device_id" => device.id}
|
||||
})
|
||||
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "discarded",
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -120, :second),
|
||||
completed_at: DateTime.utc_now(),
|
||||
args: %{"device_id" => device.id}
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/monitoring")
|
||||
# event_outcome/1 strings
|
||||
assert html =~ "Completed"
|
||||
assert html =~ "Failed"
|
||||
assert html =~ "Discarded"
|
||||
# event_border_color/1 classes
|
||||
assert html =~ "border-green-500"
|
||||
assert html =~ "border-red-500"
|
||||
assert html =~ "border-gray-400"
|
||||
# SNMP Discovery from worker_name fallback
|
||||
assert html =~ "SNMP Discovery"
|
||||
end
|
||||
|
||||
test "duration_in_words renders 'h m' for jobs stuck for hours", %{conn: conn} do
|
||||
device = device_fixture()
|
||||
two_hours_ago = DateTime.add(DateTime.utc_now(), -3 * 3600, :second)
|
||||
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
attempted_at: two_hours_ago,
|
||||
args: %{"device_id" => device.id}
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/monitoring")
|
||||
assert html =~ ~r/Running for \dh \d+m/
|
||||
end
|
||||
end
|
||||
|
||||
defp register_and_log_in_superuser(%{conn: conn}) do
|
||||
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
|
||||
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
|
||||
|
|
|
|||
|
|
@ -296,4 +296,71 @@ defmodule ToweropsWeb.AgentLive.ShowTest do
|
|||
refute html =~ "Update command sent"
|
||||
end
|
||||
end
|
||||
|
||||
describe "polling targets table — exercises assignment_source helpers" do
|
||||
test "renders direct, site, and organization assignment badges", %{
|
||||
conn: conn,
|
||||
organization: org
|
||||
} do
|
||||
agent = create_agent(org.id)
|
||||
|
||||
# Direct assignment
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Site Direct", organization_id: org.id})
|
||||
|
||||
{:ok, dev_direct} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Direct Dev",
|
||||
ip_address: "10.0.0.1",
|
||||
site_id: site.id,
|
||||
organization_id: org.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
{:ok, _} = Towerops.Agents.assign_device_to_agent(agent.id, dev_direct.id)
|
||||
|
||||
# Site assignment
|
||||
{:ok, site_assigned} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Site Assigned",
|
||||
organization_id: org.id,
|
||||
agent_token_id: agent.id
|
||||
})
|
||||
|
||||
{:ok, _dev_site} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Site Dev",
|
||||
ip_address: "10.0.0.2",
|
||||
site_id: site_assigned.id,
|
||||
organization_id: org.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
# Organization-default assignment
|
||||
{:ok, _} =
|
||||
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent.id})
|
||||
|
||||
{:ok, site_org} = Towerops.Sites.create_site(%{name: "Site Org", organization_id: org.id})
|
||||
|
||||
{:ok, _dev_org} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Org Default Dev",
|
||||
ip_address: "10.0.0.3",
|
||||
site_id: site_org.id,
|
||||
organization_id: org.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/agents/#{agent.id}")
|
||||
|
||||
assert html =~ "Polling Targets"
|
||||
# assignment_source labels
|
||||
assert html =~ "Directly assigned"
|
||||
assert html =~ "From site"
|
||||
assert html =~ "From organization"
|
||||
# source_badge_class outputs
|
||||
assert html =~ "bg-blue-100"
|
||||
assert html =~ "bg-purple-100"
|
||||
assert html =~ "bg-amber-100"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ defmodule ToweropsWeb.ConfigTimelineLiveTest do
|
|||
import Phoenix.LiveViewTest
|
||||
import Towerops.DevicesFixtures
|
||||
|
||||
alias Towerops.ConfigChanges.ConfigChangeEvent
|
||||
alias Towerops.Devices.MikrotikBackups
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{user: user} do
|
||||
|
|
@ -58,5 +61,86 @@ defmodule ToweropsWeb.ConfigTimelineLiveTest do
|
|||
_ = render_hook(view, "close_event", %{})
|
||||
assert true
|
||||
end
|
||||
|
||||
test "renders timeline rows when device has change events", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: org
|
||||
} do
|
||||
# Insert two change events in the device's recent history.
|
||||
# Need real backup ids since the template builds /backups/compare URLs.
|
||||
{:ok, b1} = MikrotikBackups.create_backup(device.id, "/ip dns set\n")
|
||||
{:ok, b2} = MikrotikBackups.create_backup(device.id, "/ip firewall add\n")
|
||||
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
earlier = DateTime.add(now, -3 * 3600, :second)
|
||||
|
||||
e1 = insert_event!(device.id, org.id, earlier, ["firewall"], 12, b1.id, b2.id)
|
||||
_e2 = insert_event!(device.id, org.id, now, ["dns", "firewall"], 4, b1.id, b2.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/config-timeline")
|
||||
html = render(view)
|
||||
|
||||
# Section badges from the template's :for over event.sections_changed
|
||||
assert html =~ "firewall"
|
||||
assert html =~ "dns"
|
||||
# change_size badge text
|
||||
assert html =~ "12 lines"
|
||||
assert html =~ "4 lines"
|
||||
|
||||
# select_event renders the modal panel (covers selected_event branches)
|
||||
_ = render_hook(view, "select_event", %{"id" => e1.id})
|
||||
html = render(view)
|
||||
assert html =~ "Change Details"
|
||||
assert html =~ "Sections Changed"
|
||||
assert html =~ "Lines Changed"
|
||||
end
|
||||
|
||||
test "selected event without diff_summary still renders the modal", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, b1} = MikrotikBackups.create_backup(device.id, "/ip dns set 1\n")
|
||||
{:ok, b2} = MikrotikBackups.create_backup(device.id, "/ip dns set 2\n")
|
||||
|
||||
e =
|
||||
%ConfigChangeEvent{}
|
||||
|> ConfigChangeEvent.changeset(%{
|
||||
device_id: device.id,
|
||||
organization_id: org.id,
|
||||
changed_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||
# Force empty sections to exercise the "Unknown" branch
|
||||
sections_changed: [],
|
||||
change_size: 0,
|
||||
diff_summary: nil,
|
||||
backup_before_id: b1.id,
|
||||
backup_after_id: b2.id
|
||||
})
|
||||
|> Towerops.Repo.insert!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/config-timeline")
|
||||
_ = render_hook(view, "select_event", %{"id" => e.id})
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "Change Details"
|
||||
# Empty sections branch renders "Unknown"
|
||||
assert html =~ "Unknown"
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_event!(device_id, org_id, changed_at, sections, change_size, before_id, after_id) do
|
||||
%ConfigChangeEvent{}
|
||||
|> ConfigChangeEvent.changeset(%{
|
||||
device_id: device_id,
|
||||
organization_id: org_id,
|
||||
changed_at: changed_at,
|
||||
diff_summary: "@@ -1,3 +1,4 @@\n+/ip firewall added\n",
|
||||
sections_changed: sections,
|
||||
change_size: change_size,
|
||||
backup_before_id: before_id,
|
||||
backup_after_id: after_id
|
||||
})
|
||||
|> Towerops.Repo.insert!()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,5 +33,33 @@ defmodule ToweropsWeb.MobileQRLiveTest do
|
|||
send(view.pid, :unexpected_message)
|
||||
assert render(view) =~ "Link Mobile App"
|
||||
end
|
||||
|
||||
test ":check_completion still pending — does not crash and re-arms timer", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/mobile/qr-login")
|
||||
send(view.pid, :check_completion)
|
||||
# Trigger a render to verify view is still alive after the handler runs
|
||||
assert render(view) =~ "Link Mobile App"
|
||||
end
|
||||
|
||||
test ":check_completion completes login when matching mobile session exists", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/mobile/qr-login")
|
||||
|
||||
# Read the live view's current qr_token and complete it externally
|
||||
qr_token = :sys.get_state(view.pid).socket.assigns.qr_token
|
||||
|
||||
{:ok, _mobile_session} =
|
||||
Towerops.MobileSessions.complete_qr_login(qr_token.token, %{
|
||||
device_name: "iPhone Test",
|
||||
os: "iOS",
|
||||
os_version: "17.0",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Test Agent"
|
||||
})
|
||||
|
||||
send(view.pid, :check_completion)
|
||||
assert render(view) =~ "authenticated successfully"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue