towerops/lib/towerops/config_changes.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

185 lines
5.6 KiB
Elixir

defmodule Towerops.ConfigChanges do
@moduledoc """
Context for managing config change events — linking MikroTik backup diffs
to time windows for performance correlation analysis.
"""
import Ecto.Query
alias Towerops.ConfigChanges.ConfigChangeEvent
alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.MikrotikBackups.Differ
alias Towerops.Repo
require Logger
# Patterns allow optional unified-diff line prefixes (+, -, " ") before the
# leading "/" so they match both raw MikroTik configs and unified-diff output.
@mikrotik_sections %{
"firewall" => ~r/^[+\- ]?\/(ip )?firewall/m,
"interface" => ~r/^[+\- ]?\/interface/m,
"routing" => ~r/^[+\- ]?\/(ip |routing )(route|ospf|bgp|rip)/m,
"wireless" => ~r/^[+\- ]?\/interface wireless/m,
"queue" => ~r/^[+\- ]?\/queue/m,
"nat" => ~r/^[+\- ]?\/(ip )?firewall nat/m,
"address" => ~r/^[+\- ]?\/ip address/m,
"dns" => ~r/^[+\- ]?\/ip dns/m,
"dhcp" => ~r/^[+\- ]?\/ip dhcp/m,
"bridge" => ~r/^[+\- ]?\/interface bridge/m,
"vlan" => ~r/^[+\- ]?\/interface vlan/m,
"snmp" => ~r/^[+\- ]?\/snmp/m,
"user" => ~r/^[+\- ]?\/user/m,
"system" => ~r/^[+\- ]?\/system/m,
"ppp" => ~r/^[+\- ]?\/ppp/m
}
@doc """
Records a config change event from two backup records.
Generates a diff, detects changed sections, and stores the event.
"""
def record_change_event(device_id, before_backup, after_backup) do
config_before =
before_backup.config_compressed
|> MikrotikBackups.decompress_config()
|> Differ.mask_sensitive_data()
config_after =
after_backup.config_compressed
|> MikrotikBackups.decompress_config()
|> Differ.mask_sensitive_data()
case Differ.generate_unified_diff(config_before, config_after) do
{:ok, ""} ->
{:ok, :no_changes}
{:ok, diff} ->
stats = Differ.calculate_diff_stats(diff)
sections = detect_changed_sections(diff)
# Truncate diff_summary to first 2000 chars for storage
summary = String.slice(diff, 0, 2000)
device = Repo.get!(Towerops.Devices.Device, device_id)
attrs = %{
device_id: device_id,
organization_id: device.organization_id,
backup_before_id: before_backup.id,
backup_after_id: after_backup.id,
changed_at: after_backup.backed_up_at,
diff_summary: summary,
sections_changed: sections,
change_size: stats.additions + stats.deletions
}
%ConfigChangeEvent{}
|> ConfigChangeEvent.changeset(attrs)
|> Repo.insert()
{:error, reason} ->
Logger.error("Failed to generate diff for config change event: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Lists config change events for a device, most recent first.
Supports `:after` and `:before` date range filters and `:limit`.
"""
def list_device_changes(device_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
ConfigChangeEvent
|> where(device_id: ^device_id)
|> apply_date_filters(opts)
|> order_by(desc: :changed_at)
|> limit(^limit)
|> Repo.all()
end
@doc """
Lists config change events for an organization, most recent first.
Supports `:after` and `:before` date range filters, `:limit`, and `:preload`.
"""
def list_org_changes(organization_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
preloads = Keyword.get(opts, :preload, [])
ConfigChangeEvent
|> where(organization_id: ^organization_id)
|> apply_date_filters(opts)
|> order_by(desc: :changed_at)
|> limit(^limit)
|> Repo.all()
|> Repo.preload(preloads)
end
@doc """
Lists config change events for devices at a specific site, most recent first.
Supports `:after`, `:before`, `:limit`, and `:preload` options.
"""
def list_site_changes(site_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
preloads = Keyword.get(opts, :preload, [:device])
device_ids =
site_id
|> Towerops.Devices.list_site_devices()
|> Enum.map(& &1.id)
if device_ids == [] do
[]
else
ConfigChangeEvent
|> where([e], e.device_id in ^device_ids)
|> apply_date_filters(opts)
|> order_by(desc: :changed_at)
|> limit(^limit)
|> Repo.all()
|> Repo.preload(preloads)
end
end
@doc """
Gets a single config change event by ID.
"""
def get_change_event!(id), do: Repo.get!(ConfigChangeEvent, id)
@doc """
Gets a config change event with preloaded associations.
"""
def get_change_event_with_preloads!(id) do
ConfigChangeEvent
|> Repo.get!(id)
|> Repo.preload([:device, :backup_before, :backup_after])
end
@doc """
Detects which MikroTik config sections were modified in a unified diff.
Returns a sorted list of section names (strings) matching sections like
`firewall`, `interface`, `routing`, etc.
"""
@spec detect_changed_sections(String.t()) :: [String.t()]
def detect_changed_sections(diff) when is_binary(diff) do
@mikrotik_sections
|> Enum.filter(fn {_name, pattern} -> Regex.match?(pattern, diff) end)
|> Enum.map(fn {name, _} -> name end)
|> Enum.sort()
end
def detect_changed_sections(_), do: []
defp apply_date_filters(query, opts) do
query
|> maybe_filter_after(Keyword.get(opts, :after))
|> maybe_filter_before(Keyword.get(opts, :before))
end
defp maybe_filter_after(query, nil), do: query
defp maybe_filter_after(query, dt), do: where(query, [e], e.changed_at >= ^dt)
defp maybe_filter_before(query, nil), do: query
defp maybe_filter_before(query, dt), do: where(query, [e], e.changed_at <= ^dt)
end