more tests and fixes
This commit is contained in:
parent
141c775230
commit
3931a9c14a
34 changed files with 8240 additions and 376 deletions
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mix test:*)",
|
||||
"Bash(mix format:*)",
|
||||
"Bash(mix credo:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ defmodule Towerops.JobMonitoring do
|
|||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Oban.Job
|
||||
alias Towerops.Repo
|
||||
|
||||
|
|
@ -15,8 +16,10 @@ defmodule Towerops.JobMonitoring do
|
|||
"Towerops.Workers.DiscoveryWorker"
|
||||
]
|
||||
|
||||
@polling_threshold_seconds 120 # 2 minutes
|
||||
@discovery_threshold_seconds 300 # 5 minutes
|
||||
# 2 minutes
|
||||
@polling_threshold_seconds 120
|
||||
# 5 minutes
|
||||
@discovery_threshold_seconds 300
|
||||
|
||||
@doc """
|
||||
Lists all currently executing polling and discovery jobs.
|
||||
|
|
@ -49,7 +52,7 @@ defmodule Towerops.JobMonitoring do
|
|||
where: j.state == "executing",
|
||||
where:
|
||||
(j.worker == "Towerops.Workers.DevicePollerWorker" and j.attempted_at < ^polling_threshold) or
|
||||
(j.worker == "Towerops.Workers.DiscoveryWorker" and j.attempted_at < ^discovery_threshold),
|
||||
(j.worker == "Towerops.Workers.DiscoveryWorker" and j.attempted_at < ^discovery_threshold),
|
||||
order_by: [asc: j.attempted_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|
|
@ -95,7 +98,7 @@ defmodule Towerops.JobMonitoring do
|
|||
defp preload_device_context(jobs) do
|
||||
Enum.map(jobs, fn job ->
|
||||
device_id = get_in(job.args, ["device_id"])
|
||||
device = device_id && Towerops.Repo.get(Towerops.Devices.Device, device_id)
|
||||
device = device_id && Repo.get(Towerops.Devices.Device, device_id)
|
||||
Map.put(job, :device, device)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule Towerops.JobMonitoring.Metrics do
|
|||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Oban.Job
|
||||
alias Towerops.Repo
|
||||
|
||||
|
|
@ -57,61 +58,57 @@ defmodule Towerops.JobMonitoring.Metrics do
|
|||
end
|
||||
|
||||
defp count_executing do
|
||||
from(j in Job,
|
||||
where: j.state == "executing",
|
||||
where: j.worker in ^@worker_names,
|
||||
select: count(j.id)
|
||||
)
|
||||
|> Repo.one()
|
||||
Repo.one(from(j in Job, where: j.state == "executing", where: j.worker in ^@worker_names, select: count(j.id)))
|
||||
end
|
||||
|
||||
defp count_queued do
|
||||
from(j in Job,
|
||||
where: j.state in ["available", "scheduled"],
|
||||
where: j.worker in ^@worker_names,
|
||||
select: count(j.id)
|
||||
Repo.one(
|
||||
from(j in Job, where: j.state in ["available", "scheduled"], where: j.worker in ^@worker_names, select: count(j.id))
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp count_completed_since(since) do
|
||||
from(j in Job,
|
||||
where: j.state == "completed",
|
||||
where: j.worker in ^@worker_names,
|
||||
where: j.completed_at >= ^since,
|
||||
select: count(j.id)
|
||||
Repo.one(
|
||||
from(j in Job,
|
||||
where: j.state == "completed",
|
||||
where: j.worker in ^@worker_names,
|
||||
where: j.completed_at >= ^since,
|
||||
select: count(j.id)
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp count_failed_since(since) do
|
||||
from(j in Job,
|
||||
where: j.state in ["retryable", "cancelled", "discarded"],
|
||||
where: j.worker in ^@worker_names,
|
||||
where: j.inserted_at >= ^since,
|
||||
select: count(j.id)
|
||||
Repo.one(
|
||||
from(j in Job,
|
||||
where: j.state in ["retryable", "cancelled", "discarded"],
|
||||
where: j.worker in ^@worker_names,
|
||||
where: j.inserted_at >= ^since,
|
||||
select: count(j.id)
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp calculate_success_rate(worker, since) do
|
||||
completed =
|
||||
from(j in Job,
|
||||
where: j.worker == ^worker,
|
||||
where: j.state == "completed",
|
||||
where: j.completed_at >= ^since,
|
||||
select: count(j.id)
|
||||
Repo.one(
|
||||
from(j in Job,
|
||||
where: j.worker == ^worker,
|
||||
where: j.state == "completed",
|
||||
where: j.completed_at >= ^since,
|
||||
select: count(j.id)
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
|
||||
failed =
|
||||
from(j in Job,
|
||||
where: j.worker == ^worker,
|
||||
where: j.state in ["retryable", "cancelled", "discarded"],
|
||||
where: j.inserted_at >= ^since,
|
||||
select: count(j.id)
|
||||
Repo.one(
|
||||
from(j in Job,
|
||||
where: j.worker == ^worker,
|
||||
where: j.state in ["retryable", "cancelled", "discarded"],
|
||||
where: j.inserted_at >= ^since,
|
||||
select: count(j.id)
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
|
||||
total = completed + failed
|
||||
|
||||
|
|
@ -141,15 +138,16 @@ defmodule Towerops.JobMonitoring.Metrics do
|
|||
|
||||
defp calculate_avg_execution_time(since) do
|
||||
result =
|
||||
from(j in Job,
|
||||
where: j.state == "completed",
|
||||
where: j.worker in ^@worker_names,
|
||||
where: j.completed_at >= ^since,
|
||||
where: not is_nil(j.attempted_at),
|
||||
where: not is_nil(j.completed_at),
|
||||
select: avg(fragment("EXTRACT(EPOCH FROM (? - ?))", j.completed_at, j.attempted_at))
|
||||
Repo.one(
|
||||
from(j in Job,
|
||||
where: j.state == "completed",
|
||||
where: j.worker in ^@worker_names,
|
||||
where: j.completed_at >= ^since,
|
||||
where: not is_nil(j.attempted_at),
|
||||
where: not is_nil(j.completed_at),
|
||||
select: avg(fragment("EXTRACT(EPOCH FROM (? - ?))", j.completed_at, j.attempted_at))
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
|
||||
case result do
|
||||
nil -> nil
|
||||
|
|
|
|||
|
|
@ -52,33 +52,7 @@ defmodule Towerops.Workers.DiscoveryWorker do
|
|||
:discard
|
||||
|
||||
device ->
|
||||
# Discovery is a one-shot operation - always discard on completion (success or failure)
|
||||
# This prevents stuck jobs from retrying indefinitely
|
||||
# Use Task.async/await with timeout to prevent jobs from hanging indefinitely
|
||||
task = Task.async(fn -> perform_discovery(device) end)
|
||||
|
||||
case Task.yield(task, @job_timeout_ms) || Task.shutdown(task) do
|
||||
{:ok, :ok} ->
|
||||
:ok
|
||||
|
||||
{:ok, {:error, reason}} ->
|
||||
Logger.warning(
|
||||
"Discovery failed for device #{device_id}, discarding job",
|
||||
device_id: device_id,
|
||||
error: reason
|
||||
)
|
||||
|
||||
:discard
|
||||
|
||||
nil ->
|
||||
Logger.error(
|
||||
"Discovery job timeout after #{@job_timeout_ms}ms, discarding to prevent retries",
|
||||
device_id: device_id,
|
||||
timeout_ms: @job_timeout_ms
|
||||
)
|
||||
|
||||
:discard
|
||||
end
|
||||
perform_device_discovery(device, device_id)
|
||||
end
|
||||
|
||||
duration = System.monotonic_time(:second) - start_time
|
||||
|
|
@ -111,6 +85,36 @@ defmodule Towerops.Workers.DiscoveryWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp perform_device_discovery(device, device_id) do
|
||||
# Discovery is a one-shot operation - always discard on completion (success or failure)
|
||||
# This prevents stuck jobs from retrying indefinitely
|
||||
# Use Task.async/await with timeout to prevent jobs from hanging indefinitely
|
||||
task = Task.async(fn -> perform_discovery(device) end)
|
||||
|
||||
case Task.yield(task, @job_timeout_ms) || Task.shutdown(task) do
|
||||
{:ok, :ok} ->
|
||||
:ok
|
||||
|
||||
{:ok, {:error, reason}} ->
|
||||
Logger.warning(
|
||||
"Discovery failed for device #{device_id}, discarding job",
|
||||
device_id: device_id,
|
||||
error: reason
|
||||
)
|
||||
|
||||
:discard
|
||||
|
||||
nil ->
|
||||
Logger.error(
|
||||
"Discovery job timeout after #{@job_timeout_ms}ms, discarding to prevent retries",
|
||||
device_id: device_id,
|
||||
timeout_ms: @job_timeout_ms
|
||||
)
|
||||
|
||||
:discard
|
||||
end
|
||||
end
|
||||
|
||||
defp perform_discovery(device) do
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
|
|
@ -421,31 +425,62 @@ defmodule Towerops.Workers.DiscoveryWorker do
|
|||
|
||||
# Check if discovery completed (last_discovery_at changed)
|
||||
true ->
|
||||
case Devices.get_device_with_details(device_id) do
|
||||
nil ->
|
||||
Logger.warning(
|
||||
"Device not found for discovery request",
|
||||
device_id: device_id
|
||||
)
|
||||
check_discovery_completion(
|
||||
device_id,
|
||||
agent_token_id,
|
||||
initial_discovery_at,
|
||||
start_time,
|
||||
timeout_ms,
|
||||
elapsed
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
{:error, :device_deleted}
|
||||
defp check_discovery_completion(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms, elapsed) do
|
||||
case Devices.get_device_with_details(device_id) do
|
||||
nil ->
|
||||
Logger.warning(
|
||||
"Device not found for discovery request",
|
||||
device_id: device_id
|
||||
)
|
||||
|
||||
device ->
|
||||
if device.last_discovery_at == initial_discovery_at do
|
||||
# Wait 500ms before checking again
|
||||
Process.sleep(500)
|
||||
wait_loop(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms)
|
||||
else
|
||||
Logger.info(
|
||||
"Agent discovery completed successfully",
|
||||
device_id: device_id,
|
||||
agent_token_id: agent_token_id,
|
||||
elapsed_ms: elapsed
|
||||
)
|
||||
{:error, :device_deleted}
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
device ->
|
||||
handle_device_discovery_status(
|
||||
device,
|
||||
device_id,
|
||||
agent_token_id,
|
||||
initial_discovery_at,
|
||||
start_time,
|
||||
timeout_ms,
|
||||
elapsed
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_device_discovery_status(
|
||||
device,
|
||||
device_id,
|
||||
agent_token_id,
|
||||
initial_discovery_at,
|
||||
start_time,
|
||||
timeout_ms,
|
||||
elapsed
|
||||
) do
|
||||
if device.last_discovery_at == initial_discovery_at do
|
||||
# Wait 500ms before checking again
|
||||
Process.sleep(500)
|
||||
wait_loop(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms)
|
||||
else
|
||||
Logger.info(
|
||||
"Agent discovery completed successfully",
|
||||
device_id: device_id,
|
||||
agent_token_id: agent_token_id,
|
||||
elapsed_ms: elapsed
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
alias Towerops.Devices.MikrotikBackups
|
||||
alias Towerops.Snmp
|
||||
alias Towerops.Snmp.AgentDiscovery
|
||||
alias Towerops.Snmp.Discovery
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -490,18 +491,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
end
|
||||
|
||||
defp build_v3_snmp_device(device, snmp_config) do
|
||||
Logger.info(
|
||||
"Building SNMPv3 device message",
|
||||
device_id: device.id,
|
||||
device_name: device.name,
|
||||
device_ip: device.ip_address,
|
||||
security_level: snmp_config.security_level || "",
|
||||
username: snmp_config.username || "",
|
||||
auth_protocol: snmp_config.auth_protocol || "",
|
||||
auth_password_present: !is_nil(snmp_config.auth_password) && snmp_config.auth_password != "",
|
||||
priv_protocol: snmp_config.priv_protocol || "",
|
||||
priv_password_present: !is_nil(snmp_config.priv_password) && snmp_config.priv_password != ""
|
||||
)
|
||||
log_v3_device_build(device, snmp_config)
|
||||
|
||||
%SnmpDevice{
|
||||
ip: device.ip_address,
|
||||
|
|
@ -518,6 +508,24 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
}
|
||||
end
|
||||
|
||||
defp log_v3_device_build(device, snmp_config) do
|
||||
auth_password_present = !is_nil(snmp_config.auth_password) && snmp_config.auth_password != ""
|
||||
priv_password_present = !is_nil(snmp_config.priv_password) && snmp_config.priv_password != ""
|
||||
|
||||
Logger.info(
|
||||
"Building SNMPv3 device message",
|
||||
device_id: device.id,
|
||||
device_name: device.name,
|
||||
device_ip: device.ip_address,
|
||||
security_level: snmp_config.security_level || "",
|
||||
username: snmp_config.username || "",
|
||||
auth_protocol: snmp_config.auth_protocol || "",
|
||||
auth_password_present: auth_password_present,
|
||||
priv_protocol: snmp_config.priv_protocol || "",
|
||||
priv_password_present: priv_password_present
|
||||
)
|
||||
end
|
||||
|
||||
defp build_v2c_snmp_device(device, snmp_config) do
|
||||
community = snmp_config.community || ""
|
||||
|
||||
|
|
@ -949,7 +957,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
|
||||
# Process complex SNMP data (neighbors, ARP, MAC, IP addresses, processors, storage)
|
||||
# using Replay adapter to reuse existing parsing logic
|
||||
if map_size(oid_values) > length(snmp_device.sensors) + (length(snmp_device.interfaces) * 6) do
|
||||
if map_size(oid_values) > length(snmp_device.sensors) + length(snmp_device.interfaces) * 6 do
|
||||
process_additional_polling_data(device, oid_values)
|
||||
end
|
||||
end
|
||||
|
|
@ -971,75 +979,34 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
|
||||
spawn(fn ->
|
||||
try do
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
alias Towerops.Snmp.ArpDiscovery
|
||||
alias Towerops.Snmp.MacDiscovery
|
||||
alias Towerops.Snmp.NeighborDiscovery
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
|
||||
# Fetch interfaces once for reuse across multiple operations
|
||||
interfaces = Towerops.Snmp.list_interfaces(snmp_device_id)
|
||||
interfaces = Snmp.list_interfaces(snmp_device_id)
|
||||
|
||||
# Add device_id to interfaces for neighbor discovery (required by build_neighbor_record)
|
||||
interfaces_with_device_id = Enum.map(interfaces, &Map.put(&1, :device_id, device_id))
|
||||
|
||||
# Process neighbors (LLDP/CDP)
|
||||
case NeighborDiscovery.discover_neighbors(client_opts, interfaces_with_device_id) do
|
||||
{:ok, neighbors} when neighbors != [] ->
|
||||
Towerops.Snmp.Discovery.save_neighbors(device_id, neighbors)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
process_neighbors(client_opts, device_id, interfaces_with_device_id)
|
||||
|
||||
# Process ARP entries
|
||||
case Towerops.Snmp.ArpDiscovery.discover_arp_table(client_opts) do
|
||||
{:ok, arp_entries} when arp_entries != [] ->
|
||||
Towerops.Snmp.Discovery.save_arp_entries(device_id, arp_entries, interfaces)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
process_arp_entries(client_opts, device_id, interfaces)
|
||||
|
||||
# Process MAC addresses
|
||||
case Towerops.Snmp.MacDiscovery.discover_mac_table(client_opts) do
|
||||
{:ok, mac_addresses} when mac_addresses != [] ->
|
||||
Towerops.Snmp.upsert_mac_addresses(device_id, mac_addresses, interfaces)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
process_mac_addresses(client_opts, device_id, interfaces)
|
||||
|
||||
# Process IP addresses
|
||||
case Base.discover_all_ip_addresses(client_opts) do
|
||||
{:ok, ip_addresses} when ip_addresses != [] ->
|
||||
discovered_device = %{
|
||||
device_id: device_id,
|
||||
interfaces: interfaces
|
||||
}
|
||||
|
||||
Towerops.Snmp.Discovery.sync_ip_addresses(discovered_device, ip_addresses)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
process_ip_addresses(client_opts, device_id, interfaces)
|
||||
|
||||
# Process processors
|
||||
case Base.discover_processors(client_opts) do
|
||||
{:ok, processors} when processors != [] ->
|
||||
discovered_device = %{id: snmp_device_id}
|
||||
Towerops.Snmp.Discovery.sync_processors(discovered_device, processors)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
process_processors(client_opts, snmp_device_id)
|
||||
|
||||
# Process storage
|
||||
case Base.discover_storage(client_opts) do
|
||||
{:ok, storage} when storage != [] ->
|
||||
discovered_device = %{id: snmp_device_id}
|
||||
Towerops.Snmp.Discovery.sync_storage(discovered_device, storage)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
process_storage(client_opts, snmp_device_id)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error(
|
||||
|
|
@ -1051,6 +1018,85 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
end)
|
||||
end
|
||||
|
||||
defp process_neighbors(client_opts, device_id, interfaces_with_device_id) do
|
||||
alias Towerops.Snmp.NeighborDiscovery
|
||||
|
||||
case NeighborDiscovery.discover_neighbors(client_opts, interfaces_with_device_id) do
|
||||
{:ok, neighbors} when neighbors != [] ->
|
||||
Discovery.save_neighbors(device_id, neighbors)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp process_arp_entries(client_opts, device_id, interfaces) do
|
||||
alias Towerops.Snmp.ArpDiscovery
|
||||
|
||||
case ArpDiscovery.discover_arp_table(client_opts) do
|
||||
{:ok, arp_entries} when arp_entries != [] ->
|
||||
Discovery.save_arp_entries(device_id, arp_entries, interfaces)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp process_mac_addresses(client_opts, device_id, interfaces) do
|
||||
alias Towerops.Snmp.MacDiscovery
|
||||
|
||||
case MacDiscovery.discover_mac_table(client_opts) do
|
||||
{:ok, mac_addresses} when mac_addresses != [] ->
|
||||
Snmp.upsert_mac_addresses(device_id, mac_addresses, interfaces)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp process_ip_addresses(client_opts, device_id, interfaces) do
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
|
||||
case Base.discover_all_ip_addresses(client_opts) do
|
||||
{:ok, ip_addresses} when ip_addresses != [] ->
|
||||
discovered_device = %{
|
||||
device_id: device_id,
|
||||
interfaces: interfaces
|
||||
}
|
||||
|
||||
Discovery.sync_ip_addresses(discovered_device, ip_addresses)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp process_processors(client_opts, snmp_device_id) do
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
|
||||
case Base.discover_processors(client_opts) do
|
||||
{:ok, processors} when processors != [] ->
|
||||
discovered_device = %{id: snmp_device_id}
|
||||
Discovery.sync_processors(discovered_device, processors)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp process_storage(client_opts, snmp_device_id) do
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
|
||||
case Base.discover_storage(client_opts) do
|
||||
{:ok, storage} when storage != [] ->
|
||||
discovered_device = %{id: snmp_device_id}
|
||||
Discovery.sync_storage(discovered_device, storage)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp process_sensor_reading(sensor, oid_values, timestamp) do
|
||||
case Map.get(oid_values, sensor.sensor_oid) do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
Supports short time periods (seconds, minutes, hours, days) and
|
||||
long time periods (months, years) for things like build timestamps.
|
||||
|
||||
Handles clock skew gracefully by treating future timestamps as "just now".
|
||||
|
||||
## Examples
|
||||
|
||||
iex> datetime = DateTime.add(DateTime.utc_now(), -65, :second)
|
||||
|
|
@ -106,6 +108,9 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
now = DateTime.utc_now()
|
||||
diff = DateTime.diff(now, datetime, :second)
|
||||
|
||||
# Clamp diff to 0 if negative (handles clock skew where datetime is in the future)
|
||||
diff = max(diff, 0)
|
||||
|
||||
cond do
|
||||
diff < 60 ->
|
||||
ngettext("%{count}s ago", "%{count}s ago", diff, count: diff)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule ToweropsWeb.Admin.MonitoringLive do
|
|||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Phoenix.LiveView.Socket
|
||||
alias Towerops.JobMonitoring
|
||||
alias Towerops.JobMonitoring.Metrics
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ defmodule ToweropsWeb.Admin.MonitoringLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@spec load_monitoring_data(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
@spec load_monitoring_data(Socket.t()) :: Socket.t()
|
||||
defp load_monitoring_data(socket) do
|
||||
executing_jobs = JobMonitoring.list_active_jobs()
|
||||
stuck_jobs = JobMonitoring.list_stuck_jobs()
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
Real-time monitoring of polling and discovery jobs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Health Metrics Cards -->
|
||||
|
||||
<!-- Health Metrics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed (1h)</h3>
|
||||
|
|
@ -47,8 +47,8 @@
|
|||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">executing now</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Operations -->
|
||||
|
||||
<!-- Active Operations -->
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Active Operations ({length(@executing_jobs)})
|
||||
|
|
@ -64,16 +64,16 @@
|
|||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<%= if job.device do %>
|
||||
<%= job.device.name %>
|
||||
{job.device.name}
|
||||
<% else %>
|
||||
Device #{get_in(job.args, ["device_id"])}
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= worker_name(job.worker) %>
|
||||
{worker_name(job.worker)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Started <%= ToweropsWeb.TimeHelpers.format_time_ago(job.attempted_at) %>
|
||||
Started {ToweropsWeb.TimeHelpers.format_time_ago(job.attempted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
|
@ -85,8 +85,8 @@
|
|||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Problems -->
|
||||
|
||||
<!-- Problems -->
|
||||
<%= if length(@stuck_jobs) > 0 or length(@failed_jobs) > 0 do %>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<h2 class="text-lg font-semibold text-red-900 dark:text-red-100 mb-4">
|
||||
|
|
@ -105,16 +105,16 @@
|
|||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<%= if job.device do %>
|
||||
<%= job.device.name %>
|
||||
{job.device.name}
|
||||
<% else %>
|
||||
Device #{get_in(job.args, ["device_id"])}
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= worker_name(job.worker) %>
|
||||
{worker_name(job.worker)}
|
||||
</p>
|
||||
<p class="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
Running for <%= duration_in_words(job.attempted_at) %>
|
||||
Running for {duration_in_words(job.attempted_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -136,17 +136,17 @@
|
|||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<%= if job.device do %>
|
||||
<%= job.device.name %>
|
||||
{job.device.name}
|
||||
<% else %>
|
||||
Device #{get_in(job.args, ["device_id"])}
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= worker_name(job.worker) %> - Attempt <%= job.attempt %>/<%= job.max_attempts %>
|
||||
{worker_name(job.worker)} - Attempt {job.attempt}/{job.max_attempts}
|
||||
</p>
|
||||
<%= if job.errors && length(job.errors) > 0 do %>
|
||||
<p class="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
<%= hd(job.errors)["error"] || "Unknown error" %>
|
||||
{hd(job.errors)["error"] || "Unknown error"}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -158,14 +158,14 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Health Metrics -->
|
||||
|
||||
<!-- Health Metrics -->
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Health Metrics
|
||||
</h2>
|
||||
|
||||
<!-- Current Activity -->
|
||||
|
||||
<!-- Current Activity -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Current Activity
|
||||
|
|
@ -197,8 +197,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Rates -->
|
||||
|
||||
<!-- Success Rates -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Success Rates
|
||||
|
|
@ -208,12 +208,12 @@
|
|||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Polling (1h)</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
<%= Float.round(@metrics.polling_success_rate_1h * 100, 1) %>%
|
||||
{Float.round(@metrics.polling_success_rate_1h * 100, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class={"bg-green-600 h-2 rounded-full transition-all"}
|
||||
class="bg-green-600 h-2 rounded-full transition-all"
|
||||
style={"width: #{@metrics.polling_success_rate_1h * 100}%"}
|
||||
>
|
||||
</div>
|
||||
|
|
@ -224,12 +224,12 @@
|
|||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Discovery (1h)</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
<%= Float.round(@metrics.discovery_success_rate_1h * 100, 1) %>%
|
||||
{Float.round(@metrics.discovery_success_rate_1h * 100, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class={"bg-blue-600 h-2 rounded-full transition-all"}
|
||||
class="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={"width: #{@metrics.discovery_success_rate_1h * 100}%"}
|
||||
>
|
||||
</div>
|
||||
|
|
@ -237,8 +237,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance -->
|
||||
|
||||
<!-- Performance -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Performance
|
||||
|
|
@ -248,7 +248,7 @@
|
|||
<dt class="text-gray-600 dark:text-gray-400">Avg Execution Time</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
<%= if @metrics.avg_execution_time_seconds do %>
|
||||
<%= Float.round(@metrics.avg_execution_time_seconds, 1) %>s
|
||||
{Float.round(@metrics.avg_execution_time_seconds, 1)}s
|
||||
<% else %>
|
||||
N/A
|
||||
<% end %>
|
||||
|
|
@ -262,8 +262,8 @@
|
|||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Queue Depths -->
|
||||
|
||||
<!-- Queue Depths -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Queue Depths
|
||||
|
|
@ -290,8 +290,8 @@
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent Activity
|
||||
|
|
@ -307,17 +307,17 @@
|
|||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<%= if job.device do %>
|
||||
<%= job.device.name %>
|
||||
{job.device.name}
|
||||
<% else %>
|
||||
Device #{get_in(job.args, ["device_id"])}
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<%= worker_name(job.worker) %> · <%= event_outcome(job) %>
|
||||
{worker_name(job.worker)} · {event_outcome(job)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500 whitespace-nowrap ml-2">
|
||||
<%= ToweropsWeb.TimeHelpers.format_time_ago(job.completed_at || job.updated_at) %>
|
||||
{ToweropsWeb.TimeHelpers.format_time_ago(job.completed_at || job.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ defmodule Towerops.JobsFixtures do
|
|||
merged_attrs =
|
||||
Map.merge(
|
||||
default_attrs,
|
||||
attrs |> Map.new(fn {k, v} -> {to_atom_key(k), v} end)
|
||||
Map.new(attrs, fn {k, v} -> {to_atom_key(k), v} end)
|
||||
)
|
||||
|
||||
job = struct!(Oban.Job, merged_attrs)
|
||||
|
|
|
|||
|
|
@ -1,118 +1,42 @@
|
|||
defmodule Towerops.Accounts.HIBPTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Accounts.HIBP
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "check_password/1" do
|
||||
test "returns breach count for compromised password" do
|
||||
# Password: "password123"
|
||||
# SHA-1: 482C811DA5D5B4BC6D497FFA98491E38
|
||||
# Prefix: 482C8
|
||||
# Suffix: 11DA5D5B4BC6D497FFA98491E38
|
||||
|
||||
response_body = """
|
||||
11DA5D5B4BC6D497FFA98491E38:123456
|
||||
AABBCCDD11223344556677889900AABB:789
|
||||
"""
|
||||
|
||||
Req.Test.stub(:test, fn conn ->
|
||||
assert conn.request_path == "/range/482C8"
|
||||
Req.Test.json(conn, %{status: 200, body: response_body})
|
||||
end)
|
||||
|
||||
Req.Test.expect(:test, fn conn ->
|
||||
Req.Test.text(conn, response_body)
|
||||
end)
|
||||
|
||||
# Note: This test will fail until we integrate Req.Test properly
|
||||
# For now, this demonstrates the expected behavior
|
||||
# assert {:ok, 123_456} = HIBP.check_password("password123")
|
||||
end
|
||||
|
||||
test "returns 0 for clean password" do
|
||||
# Password that doesn't exist in response
|
||||
_response_body = """
|
||||
AABBCCDD11223344556677889900AABB:789
|
||||
BBCCDDEE22334455667788990011BBCC:456
|
||||
"""
|
||||
|
||||
# This would require proper Req mocking setup
|
||||
# assert {:ok, 0} = HIBP.check_password("very-unique-password-12345")
|
||||
end
|
||||
|
||||
test "handles empty password gracefully" do
|
||||
assert {:ok, :unknown} = HIBP.check_password("")
|
||||
end
|
||||
|
||||
test "handles nil password gracefully" do
|
||||
describe "check_password/1 with non-binary input" do
|
||||
test "returns {:ok, :unknown} for nil" do
|
||||
assert {:ok, :unknown} = HIBP.check_password(nil)
|
||||
end
|
||||
|
||||
test "handles unicode passwords" do
|
||||
# Unicode password should hash correctly
|
||||
_password = "пароль123🔒"
|
||||
# Should not crash and should return a valid response
|
||||
# Actual API call would happen here in integration test
|
||||
test "returns {:ok, :unknown} for empty string" do
|
||||
assert {:ok, :unknown} = HIBP.check_password("")
|
||||
end
|
||||
|
||||
test "returns {:ok, :unknown} for integer" do
|
||||
assert {:ok, :unknown} = HIBP.check_password(123)
|
||||
end
|
||||
|
||||
test "returns {:ok, :unknown} for list" do
|
||||
assert {:ok, :unknown} = HIBP.check_password([])
|
||||
end
|
||||
|
||||
test "returns {:ok, :unknown} for atom" do
|
||||
assert {:ok, :unknown} = HIBP.check_password(:foo)
|
||||
end
|
||||
end
|
||||
|
||||
describe "k-anonymity implementation" do
|
||||
test "only sends first 5 characters of hash to API" do
|
||||
# This would require inspecting the actual HTTP request
|
||||
# Verification that only prefix is sent, not full hash
|
||||
describe "check_password/1 with real API" do
|
||||
@tag :integration
|
||||
test "finds commonly breached password" do
|
||||
assert {:ok, count} = HIBP.check_password("password")
|
||||
assert is_integer(count)
|
||||
assert count > 0
|
||||
end
|
||||
|
||||
test "password never sent in plaintext" do
|
||||
# Verification that password is hashed before any network call
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
test "handles API timeout gracefully" do
|
||||
# Mock timeout response
|
||||
# assert {:ok, :unknown} = HIBP.check_password("test-password")
|
||||
end
|
||||
|
||||
test "handles network errors gracefully" do
|
||||
# Mock network error
|
||||
# assert {:ok, :unknown} = HIBP.check_password("test-password")
|
||||
end
|
||||
|
||||
test "handles rate limiting (429) gracefully" do
|
||||
# Mock 429 response
|
||||
# assert {:ok, :unknown} = HIBP.check_password("test-password")
|
||||
end
|
||||
|
||||
test "handles 5xx server errors gracefully" do
|
||||
# Mock 500/503 response
|
||||
# assert {:ok, :unknown} = HIBP.check_password("test-password")
|
||||
end
|
||||
|
||||
test "handles malformed API response gracefully" do
|
||||
# Mock invalid response format
|
||||
# assert {:ok, :unknown} = HIBP.check_password("test-password")
|
||||
end
|
||||
end
|
||||
|
||||
describe "breach count parsing" do
|
||||
test "parses single result correctly" do
|
||||
# Test parsing logic directly
|
||||
end
|
||||
|
||||
test "parses multiple results and finds correct match" do
|
||||
# Test finding specific hash in list
|
||||
end
|
||||
|
||||
test "returns 0 when hash not found in results" do
|
||||
# Test no match scenario
|
||||
end
|
||||
|
||||
test "handles results with varying breach counts" do
|
||||
# Test different count formats
|
||||
@tag :integration
|
||||
test "returns 0 for likely-unique password" do
|
||||
unique = Base.encode64(:crypto.strong_rand_bytes(32))
|
||||
assert {:ok, 0} = HIBP.check_password(unique)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
78
test/towerops/accounts/login_attempt_test.exs
Normal file
78
test/towerops/accounts/login_attempt_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
defmodule Towerops.Accounts.LoginAttemptTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts.LoginAttempt
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset for successful login" do
|
||||
changeset =
|
||||
LoginAttempt.changeset(%LoginAttempt{}, %{
|
||||
success: true,
|
||||
method: "password",
|
||||
ip_address: "192.168.1.1",
|
||||
email: "user@example.com"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "valid changeset for failed login with failure_reason" do
|
||||
changeset =
|
||||
LoginAttempt.changeset(%LoginAttempt{}, %{
|
||||
success: false,
|
||||
method: "password",
|
||||
ip_address: "192.168.1.1",
|
||||
failure_reason: "invalid_credentials"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "error when success is true but failure_reason is present" do
|
||||
changeset =
|
||||
LoginAttempt.changeset(%LoginAttempt{}, %{
|
||||
success: true,
|
||||
method: "password",
|
||||
ip_address: "192.168.1.1",
|
||||
failure_reason: "invalid_credentials"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "must be nil when login is successful" in errors_on(changeset).failure_reason
|
||||
end
|
||||
|
||||
test "error when success is false but failure_reason is missing" do
|
||||
changeset =
|
||||
LoginAttempt.changeset(%LoginAttempt{}, %{
|
||||
success: false,
|
||||
method: "password",
|
||||
ip_address: "192.168.1.1"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "is required when login fails" in errors_on(changeset).failure_reason
|
||||
end
|
||||
end
|
||||
|
||||
describe "anonymize_changeset/1" do
|
||||
test "sets user_id to nil and records anonymized_at timestamp" do
|
||||
user = user_fixture()
|
||||
|
||||
login_attempt =
|
||||
Towerops.Repo.insert!(%LoginAttempt{
|
||||
user_id: user.id,
|
||||
email: "user@example.com",
|
||||
success: true,
|
||||
method: "password",
|
||||
ip_address: "192.168.1.1"
|
||||
})
|
||||
|
||||
changeset = LoginAttempt.anonymize_changeset(login_attempt)
|
||||
|
||||
assert changeset.changes.user_id == nil
|
||||
assert %DateTime{} = changeset.changes.anonymized_at
|
||||
end
|
||||
end
|
||||
end
|
||||
118
test/towerops/accounts/policy_version_test.exs
Normal file
118
test/towerops/accounts/policy_version_test.exs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
defmodule Towerops.Accounts.PolicyVersionTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.Accounts.PolicyVersion
|
||||
|
||||
@valid_attrs %{
|
||||
policy_type: "privacy_policy",
|
||||
version: "1.0",
|
||||
content: "This is the full privacy policy content.",
|
||||
effective_date: ~U[2025-01-01 00:00:00Z]
|
||||
}
|
||||
|
||||
describe "policy_types/0" do
|
||||
test "returns list of valid policy types" do
|
||||
assert PolicyVersion.policy_types() == ["privacy_policy", "terms_of_service"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with all required fields" do
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, @valid_attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires policy_type" do
|
||||
attrs = Map.delete(@valid_attrs, :policy_type)
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).policy_type
|
||||
end
|
||||
|
||||
test "requires version" do
|
||||
attrs = Map.delete(@valid_attrs, :version)
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).version
|
||||
end
|
||||
|
||||
test "requires content" do
|
||||
attrs = Map.delete(@valid_attrs, :content)
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).content
|
||||
end
|
||||
|
||||
test "requires effective_date" do
|
||||
attrs = Map.delete(@valid_attrs, :effective_date)
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).effective_date
|
||||
end
|
||||
|
||||
test "rejects invalid policy_type" do
|
||||
attrs = Map.put(@valid_attrs, :policy_type, "invalid_type")
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).policy_type
|
||||
end
|
||||
|
||||
test "accepts privacy_policy as policy_type" do
|
||||
attrs = Map.put(@valid_attrs, :policy_type, "privacy_policy")
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "accepts terms_of_service as policy_type" do
|
||||
attrs = Map.put(@valid_attrs, :policy_type, "terms_of_service")
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "rejects empty version" do
|
||||
attrs = Map.put(@valid_attrs, :version, "")
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).version
|
||||
end
|
||||
|
||||
test "rejects version longer than 20 characters" do
|
||||
attrs = Map.put(@valid_attrs, :version, String.duplicate("a", 21))
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "should be at most 20 character(s)" in errors_on(changeset).version
|
||||
end
|
||||
|
||||
test "accepts version at maximum length of 20 characters" do
|
||||
attrs = Map.put(@valid_attrs, :version, String.duplicate("a", 20))
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "rejects content shorter than 10 characters" do
|
||||
attrs = Map.put(@valid_attrs, :content, "short")
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "should be at least 10 character(s)" in errors_on(changeset).content
|
||||
end
|
||||
|
||||
test "accepts content at minimum length of 10 characters" do
|
||||
attrs = Map.put(@valid_attrs, :content, String.duplicate("a", 10))
|
||||
changeset = PolicyVersion.changeset(%PolicyVersion{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
141
test/towerops/accounts/scope_test.exs
Normal file
141
test/towerops/accounts/scope_test.exs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
defmodule Towerops.Accounts.ScopeTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
defp build_user(attrs \\ %{}) do
|
||||
defaults = %{
|
||||
id: Ecto.UUID.generate(),
|
||||
is_superuser: false,
|
||||
timezone: "America/Chicago"
|
||||
}
|
||||
|
||||
struct!(User, Map.merge(defaults, attrs))
|
||||
end
|
||||
|
||||
defp build_organization(attrs \\ %{}) do
|
||||
defaults = %{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Test Org",
|
||||
slug: "test-org"
|
||||
}
|
||||
|
||||
struct!(Organization, Map.merge(defaults, attrs))
|
||||
end
|
||||
|
||||
describe "for_user/1" do
|
||||
test "creates scope from user" do
|
||||
user = build_user()
|
||||
scope = Scope.for_user(user)
|
||||
|
||||
assert %Scope{} = scope
|
||||
assert scope.user == user
|
||||
assert scope.superuser == nil
|
||||
assert scope.impersonating? == false
|
||||
end
|
||||
|
||||
test "uses user timezone" do
|
||||
user = build_user(%{timezone: "America/Chicago"})
|
||||
scope = Scope.for_user(user)
|
||||
|
||||
assert scope.timezone == "America/Chicago"
|
||||
end
|
||||
|
||||
test "defaults to UTC when user has no timezone" do
|
||||
user = build_user(%{timezone: nil})
|
||||
scope = Scope.for_user(user)
|
||||
|
||||
assert scope.timezone == "UTC"
|
||||
end
|
||||
|
||||
test "returns nil for nil user" do
|
||||
assert Scope.for_user(nil) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "for_impersonation/2" do
|
||||
test "creates impersonation scope" do
|
||||
superuser = build_user(%{is_superuser: true})
|
||||
target = build_user(%{timezone: "Europe/London"})
|
||||
|
||||
scope = Scope.for_impersonation(superuser, target)
|
||||
|
||||
assert %Scope{} = scope
|
||||
assert scope.user == target
|
||||
assert scope.superuser == superuser
|
||||
assert scope.timezone == "Europe/London"
|
||||
end
|
||||
|
||||
test "sets impersonating? flag" do
|
||||
superuser = build_user(%{is_superuser: true})
|
||||
target = build_user()
|
||||
|
||||
scope = Scope.for_impersonation(superuser, target)
|
||||
|
||||
assert scope.impersonating? == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "put_organization/2" do
|
||||
test "adds organization to scope" do
|
||||
user = build_user()
|
||||
scope = Scope.for_user(user)
|
||||
org = build_organization()
|
||||
|
||||
updated_scope = Scope.put_organization(scope, org)
|
||||
|
||||
assert updated_scope.organization == org
|
||||
end
|
||||
|
||||
test "clears organization when nil" do
|
||||
user = build_user()
|
||||
org = build_organization()
|
||||
|
||||
scope =
|
||||
user
|
||||
|> Scope.for_user()
|
||||
|> Scope.put_organization(org)
|
||||
|
||||
assert scope.organization == org
|
||||
|
||||
cleared = Scope.put_organization(scope, nil)
|
||||
assert cleared.organization == nil
|
||||
end
|
||||
|
||||
test "returns nil when scope is nil" do
|
||||
org = build_organization()
|
||||
assert Scope.put_organization(nil, org) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "superuser?/1" do
|
||||
test "returns true for superuser" do
|
||||
user = build_user(%{is_superuser: true})
|
||||
scope = Scope.for_user(user)
|
||||
|
||||
assert Scope.superuser?(scope) == true
|
||||
end
|
||||
|
||||
test "returns true for impersonating superuser" do
|
||||
superuser = build_user(%{is_superuser: true})
|
||||
target = build_user(%{is_superuser: false})
|
||||
|
||||
scope = Scope.for_impersonation(superuser, target)
|
||||
|
||||
assert Scope.superuser?(scope) == true
|
||||
end
|
||||
|
||||
test "returns false for regular user" do
|
||||
user = build_user(%{is_superuser: false})
|
||||
scope = Scope.for_user(user)
|
||||
|
||||
refute Scope.superuser?(scope)
|
||||
end
|
||||
|
||||
test "returns false for nil" do
|
||||
assert Scope.superuser?(nil) == false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -42,6 +42,18 @@ defmodule Towerops.Accounts.UserConsentTest do
|
|||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "uses default struct when called with 1 argument", %{user: user} do
|
||||
attrs = %{
|
||||
user_id: user.id,
|
||||
consent_type: "terms_of_service",
|
||||
version: "1.0",
|
||||
granted_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = UserConsent.grant_changeset(attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires user_id" do
|
||||
attrs = %{
|
||||
consent_type: "privacy_policy",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
342
test/towerops/admin/audit_logger_test.exs
Normal file
342
test/towerops/admin/audit_logger_test.exs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
defmodule Towerops.Admin.AuditLoggerTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Admin.AuditLog
|
||||
alias Towerops.Admin.AuditLogger
|
||||
|
||||
setup do
|
||||
actor = user_fixture()
|
||||
target = user_fixture()
|
||||
|
||||
%{actor: actor, target: target}
|
||||
end
|
||||
|
||||
describe "log_event/3" do
|
||||
test "creates an audit log with a conn", %{actor: actor, target: target} do
|
||||
conn =
|
||||
:get
|
||||
|> Plug.Test.conn("/admin/users")
|
||||
|> Plug.Conn.put_req_header("user-agent", "TestBrowser/1.0")
|
||||
|> Plug.Conn.put_req_header("x-forwarded-for", "203.0.113.50")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_viewed", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: target.id,
|
||||
metadata: %{reason: "support ticket"}
|
||||
)
|
||||
|
||||
assert log.action == "user_data_viewed"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.target_user_id == target.id
|
||||
assert log.ip_address.address == "203.0.113.50"
|
||||
assert log.user_agent == "TestBrowser/1.0"
|
||||
assert log.request_path == "/admin/users"
|
||||
assert log.metadata.reason == "support ticket"
|
||||
end
|
||||
|
||||
test "creates an audit log without a conn (nil)", %{actor: actor} do
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", nil,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id,
|
||||
metadata: %{export_format: "json"}
|
||||
)
|
||||
|
||||
assert log.action == "user_data_exported"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.ip_address == nil
|
||||
assert log.user_agent == nil
|
||||
assert log.request_path == nil
|
||||
end
|
||||
|
||||
test "creates an audit log with no opts", %{actor: _actor} do
|
||||
conn = Plug.Test.conn(:get, "/test")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn)
|
||||
|
||||
assert log.action == "user_data_exported"
|
||||
assert log.superuser_id == nil
|
||||
assert log.target_user_id == nil
|
||||
assert log.request_path == "/test"
|
||||
end
|
||||
|
||||
test "returns error for invalid action" do
|
||||
assert {:error, changeset} = AuditLogger.log_event("bogus_action")
|
||||
assert %{action: ["is invalid"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "stores data_accessed when provided", %{actor: actor, target: target} do
|
||||
data = %{fields: ["email", "phone"]}
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_viewed", nil,
|
||||
actor_id: actor.id,
|
||||
target_user_id: target.id,
|
||||
data_accessed: data
|
||||
)
|
||||
|
||||
assert log.data_accessed.fields == ["email", "phone"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_user_data_viewed/4" do
|
||||
test "logs user data viewed event with conn metadata", %{actor: actor, target: target} do
|
||||
conn =
|
||||
:get
|
||||
|> Plug.Test.conn("/admin/users/#{target.id}")
|
||||
|> Plug.Conn.put_req_header("user-agent", "Mozilla/5.0")
|
||||
|> Plug.Conn.put_req_header("x-forwarded-for", "10.0.0.1")
|
||||
|
||||
data_accessed = %{fields: ["email", "name"]}
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_user_data_viewed(conn, actor.id, target.id, data_accessed)
|
||||
|
||||
assert log.action == "user_data_viewed"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.target_user_id == target.id
|
||||
assert log.data_accessed.fields == ["email", "name"]
|
||||
assert log.ip_address.address == "10.0.0.1"
|
||||
assert log.user_agent == "Mozilla/5.0"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_user_data_exported/2" do
|
||||
test "logs user data export event", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:post, "/settings/export")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_user_data_exported(conn, actor.id)
|
||||
|
||||
assert log.action == "user_data_exported"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.target_user_id == actor.id
|
||||
assert log.metadata.export_format == "json"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_user_profile_updated/3" do
|
||||
test "logs profile update with changed fields", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:put, "/settings/profile")
|
||||
fields_changed = ["email", "name"]
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_user_profile_updated(conn, actor.id, fields_changed)
|
||||
|
||||
assert log.action == "user_profile_updated"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.target_user_id == actor.id
|
||||
assert log.data_accessed.fields_changed == ["email", "name"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_device_created/4" do
|
||||
test "logs device creation with metadata", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:post, "/devices")
|
||||
device_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_device_created(conn, actor.id, device_id, "Core Router")
|
||||
|
||||
assert log.action == "device_created"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.device_id == device_id
|
||||
assert log.metadata.device_name == "Core Router"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_device_updated/4" do
|
||||
test "logs device update with changed fields", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:put, "/devices/1")
|
||||
device_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_device_updated(conn, actor.id, device_id, ["hostname", "ip"])
|
||||
|
||||
assert log.action == "device_updated"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.device_id == device_id
|
||||
assert log.data_accessed.fields_changed == ["hostname", "ip"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_device_deleted/4" do
|
||||
test "logs device deletion with metadata", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:delete, "/devices/1")
|
||||
device_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_device_deleted(conn, actor.id, device_id, "Old Switch")
|
||||
|
||||
assert log.action == "device_deleted"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.device_id == device_id
|
||||
assert log.metadata.device_name == "Old Switch"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_org_data_accessed/4" do
|
||||
test "logs organization data access", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:get, "/orgs/settings")
|
||||
org_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_org_data_accessed(conn, actor.id, org_id, "viewed_members")
|
||||
|
||||
assert log.action == "org_data_accessed"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.organization_id == org_id
|
||||
assert log.metadata.action == "viewed_members"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_monitoring_data_queried/4" do
|
||||
test "logs monitoring query event", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:get, "/monitoring")
|
||||
device_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_monitoring_data_queried(conn, actor.id, device_id, "24h")
|
||||
|
||||
assert log.action == "monitoring_data_queried"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.device_id == device_id
|
||||
assert log.metadata.time_range == "24h"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_failed_access_attempt/4" do
|
||||
test "logs failed access with resource and reason", %{actor: actor} do
|
||||
conn =
|
||||
:get
|
||||
|> Plug.Test.conn("/admin/secret")
|
||||
|> Plug.Conn.put_req_header("x-forwarded-for", "192.168.1.100")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_failed_access_attempt(
|
||||
conn,
|
||||
actor.id,
|
||||
"/admin/secret",
|
||||
"insufficient_permissions"
|
||||
)
|
||||
|
||||
assert log.action == "failed_access_attempt"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.resource == "/admin/secret"
|
||||
assert log.metadata.reason == "insufficient_permissions"
|
||||
assert log.ip_address.address == "192.168.1.100"
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_privilege_escalation/5" do
|
||||
test "logs role change with context", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:post, "/admin/roles")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_privilege_escalation(
|
||||
conn,
|
||||
actor.id,
|
||||
"member",
|
||||
"admin",
|
||||
"org_promotion"
|
||||
)
|
||||
|
||||
assert log.action == "privilege_escalation"
|
||||
assert log.superuser_id == actor.id
|
||||
assert log.metadata.from_role == "member"
|
||||
assert log.metadata.to_role == "admin"
|
||||
assert log.metadata.context == "org_promotion"
|
||||
end
|
||||
end
|
||||
|
||||
describe "IP address extraction" do
|
||||
test "extracts IP from x-forwarded-for header", %{actor: actor} do
|
||||
conn =
|
||||
:get
|
||||
|> Plug.Test.conn("/test")
|
||||
|> Plug.Conn.put_req_header("x-forwarded-for", "198.51.100.42")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id
|
||||
)
|
||||
|
||||
assert log.ip_address.address == "198.51.100.42"
|
||||
end
|
||||
|
||||
test "extracts first IP from comma-separated x-forwarded-for", %{actor: actor} do
|
||||
conn =
|
||||
:get
|
||||
|> Plug.Test.conn("/test")
|
||||
|> Plug.Conn.put_req_header("x-forwarded-for", "198.51.100.42, 10.0.0.1, 172.16.0.1")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id
|
||||
)
|
||||
|
||||
assert log.ip_address.address == "198.51.100.42"
|
||||
end
|
||||
|
||||
test "falls back to remote_ip when x-forwarded-for is absent", %{actor: actor} do
|
||||
conn = %{Plug.Test.conn(:get, "/test") | remote_ip: {10, 20, 30, 40}}
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id
|
||||
)
|
||||
|
||||
assert log.ip_address.address == "10.20.30.40"
|
||||
end
|
||||
end
|
||||
|
||||
describe "user agent extraction" do
|
||||
test "extracts user-agent header from conn", %{actor: actor} do
|
||||
conn =
|
||||
:get
|
||||
|> Plug.Test.conn("/test")
|
||||
|> Plug.Conn.put_req_header("user-agent", "CustomAgent/2.0")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id
|
||||
)
|
||||
|
||||
assert log.user_agent == "CustomAgent/2.0"
|
||||
end
|
||||
|
||||
test "returns nil user_agent when header is absent", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:get, "/test")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id
|
||||
)
|
||||
|
||||
assert log.user_agent == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "request path extraction" do
|
||||
test "extracts request_path from conn", %{actor: actor} do
|
||||
conn = Plug.Test.conn(:get, "/admin/dashboard")
|
||||
|
||||
assert {:ok, %AuditLog{} = log} =
|
||||
AuditLogger.log_event("user_data_exported", conn,
|
||||
actor_id: actor.id,
|
||||
target_user_id: actor.id
|
||||
)
|
||||
|
||||
assert log.request_path == "/admin/dashboard"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -488,6 +488,831 @@ defmodule Towerops.Agent.ValidatorTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "validate_heartbeat/1 - hostname length" do
|
||||
test "rejects hostname exceeding 255 characters" do
|
||||
long_hostname = String.duplicate("a", 256)
|
||||
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: long_hostname,
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_hostname, "Hostname too long"}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "accepts hostname at exactly 255 characters" do
|
||||
# Build a valid RFC 1123 hostname at max length: segments like "a" joined by dots
|
||||
# Each segment must start/end with alnum, max 63 chars
|
||||
segment = String.duplicate("a", 63)
|
||||
# 63 * 4 + 3 dots = 255
|
||||
hostname = Enum.join([segment, segment, segment, segment], ".")
|
||||
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: hostname,
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_heartbeat/1 - version edge cases" do
|
||||
test "accepts version with pre-release suffix" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.2.3-beta.1",
|
||||
hostname: "agent01",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "rejects version with spaces" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.2 .3",
|
||||
hostname: "agent01",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "rejects version exceeding 255 characters" do
|
||||
long_version = "1.2.3-" <> String.duplicate("a", 250)
|
||||
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: long_version,
|
||||
hostname: "agent01",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_heartbeat/1 - uptime boundary" do
|
||||
test "accepts uptime at max value (4_294_967_295)" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "agent01",
|
||||
uptime_seconds: 4_294_967_295,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "rejects uptime at max value + 1" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "agent01",
|
||||
uptime_seconds: 4_294_967_296,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_uptime, _}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - empty status" do
|
||||
test "accepts empty status on sensor reading (backward compat)" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 23.5,
|
||||
status: "",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - all valid statuses" do
|
||||
for status <- ["success", "failure", "up", "down", "ok", "error", "warning"] do
|
||||
test "accepts status '#{status}'" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 23.5,
|
||||
status: unquote(status),
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - neighbor discovery string lengths" do
|
||||
test "rejects remote_chassis_id exceeding 255 characters" do
|
||||
discovery = %NeighborDiscovery{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
protocol: "lldp",
|
||||
remote_chassis_id: String.duplicate("x", 256),
|
||||
remote_system_name: "",
|
||||
remote_system_description: "",
|
||||
remote_platform: "",
|
||||
remote_port_id: "",
|
||||
remote_port_description: "",
|
||||
remote_address: "",
|
||||
remote_capabilities: [],
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:neighbor_discovery, discovery}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:string_too_long, msg}} = Validator.validate_metric_batch(binary)
|
||||
assert msg =~ "remote_chassis_id"
|
||||
end
|
||||
|
||||
test "rejects remote_system_description exceeding 10_000 characters" do
|
||||
discovery = %NeighborDiscovery{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
protocol: "lldp",
|
||||
remote_chassis_id: "00:11:22:33:44:55",
|
||||
remote_system_name: "switch01",
|
||||
remote_system_description: String.duplicate("d", 10_001),
|
||||
remote_platform: "",
|
||||
remote_port_id: "",
|
||||
remote_port_description: "",
|
||||
remote_address: "",
|
||||
remote_capabilities: [],
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:neighbor_discovery, discovery}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:string_too_long, msg}} = Validator.validate_metric_batch(binary)
|
||||
assert msg =~ "remote_system_description"
|
||||
end
|
||||
|
||||
test "accepts cdp protocol" do
|
||||
discovery = %NeighborDiscovery{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
protocol: "cdp",
|
||||
remote_chassis_id: "00:11:22:33:44:55",
|
||||
remote_system_name: "switch01",
|
||||
remote_system_description: "",
|
||||
remote_platform: "",
|
||||
remote_port_id: "",
|
||||
remote_port_description: "",
|
||||
remote_address: "",
|
||||
remote_capabilities: [],
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:neighbor_discovery, discovery}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - monitoring check edge cases" do
|
||||
test "rejects negative response time" do
|
||||
check = %MonitoringCheck{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: "success",
|
||||
response_time_ms: -1.0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:monitoring_check, check}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_response_time, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "accepts response time at max boundary (60_000ms)" do
|
||||
check = %MonitoringCheck{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: "success",
|
||||
response_time_ms: 60_000.0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:monitoring_check, check}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects response time just over max boundary" do
|
||||
check = %MonitoringCheck{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: "success",
|
||||
response_time_ms: 60_001.0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:monitoring_check, check}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_response_time, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "accepts zero response time" do
|
||||
check = %MonitoringCheck{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: "up",
|
||||
response_time_ms: 0.0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:monitoring_check, check}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - timestamp boundaries" do
|
||||
test "accepts timestamp at zero" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 1.0,
|
||||
status: "ok",
|
||||
timestamp: 0
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "accepts timestamp at max value (2_147_483_647)" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 1.0,
|
||||
status: "ok",
|
||||
timestamp: 2_147_483_647
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects timestamp at max value + 1" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 1.0,
|
||||
status: "ok",
|
||||
timestamp: 2_147_483_648
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_timestamp, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects negative timestamp" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 1.0,
|
||||
status: "ok",
|
||||
timestamp: -1
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:sensor_reading, reading}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_timestamp, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - empty batch" do
|
||||
test "accepts empty metrics list" do
|
||||
batch = %MetricBatch{metrics: []}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, validated} = Validator.validate_metric_batch(binary)
|
||||
assert validated.metrics == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - multiple metrics" do
|
||||
test "validates batch with multiple valid metrics of different types" do
|
||||
reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 23.5,
|
||||
status: "ok",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
stat = %InterfaceStat{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
if_in_octets: 1000,
|
||||
if_out_octets: 2000,
|
||||
if_in_errors: 0,
|
||||
if_out_errors: 0,
|
||||
if_in_discards: 0,
|
||||
if_out_discards: 0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [
|
||||
%Metric{metric_type: {:sensor_reading, reading}},
|
||||
%Metric{metric_type: {:interface_stat, stat}}
|
||||
]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, validated} = Validator.validate_metric_batch(binary)
|
||||
assert length(validated.metrics) == 2
|
||||
end
|
||||
|
||||
test "rejects batch when second metric is invalid" do
|
||||
valid_reading = %SensorReading{
|
||||
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
value: 23.5,
|
||||
status: "ok",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
invalid_reading = %SensorReading{
|
||||
sensor_id: "not-a-uuid",
|
||||
value: 1.0,
|
||||
status: "ok",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [
|
||||
%Metric{metric_type: {:sensor_reading, valid_reading}},
|
||||
%Metric{metric_type: {:sensor_reading, invalid_reading}}
|
||||
]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_sensor_id, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_snmp_result/1 - timestamp" do
|
||||
test "rejects SNMP result with negative timestamp" do
|
||||
result = %SnmpResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_type: 1,
|
||||
oid_values: %{},
|
||||
timestamp: -1
|
||||
}
|
||||
|
||||
binary = SnmpResult.encode(result)
|
||||
assert {:error, {:invalid_timestamp, _}} = Validator.validate_snmp_result(binary)
|
||||
end
|
||||
|
||||
test "rejects SNMP result with timestamp exceeding max" do
|
||||
result = %SnmpResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_type: 1,
|
||||
oid_values: %{},
|
||||
timestamp: 2_147_483_648
|
||||
}
|
||||
|
||||
binary = SnmpResult.encode(result)
|
||||
assert {:error, {:invalid_timestamp, _}} = Validator.validate_snmp_result(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_agent_error/1 - edge cases" do
|
||||
test "rejects empty job_id" do
|
||||
error = %AgentError{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "",
|
||||
message: "Some error"
|
||||
}
|
||||
|
||||
binary = AgentError.encode(error)
|
||||
assert {:error, {:invalid_job_id, _}} = Validator.validate_agent_error(binary)
|
||||
end
|
||||
|
||||
test "accepts job_id at 255 characters" do
|
||||
error = %AgentError{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: String.duplicate("j", 255),
|
||||
message: "Some error"
|
||||
}
|
||||
|
||||
binary = AgentError.encode(error)
|
||||
assert {:ok, _} = Validator.validate_agent_error(binary)
|
||||
end
|
||||
|
||||
test "rejects job_id exceeding 255 characters" do
|
||||
error = %AgentError{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: String.duplicate("j", 256),
|
||||
message: "Some error"
|
||||
}
|
||||
|
||||
binary = AgentError.encode(error)
|
||||
assert {:error, {:invalid_job_id, _}} = Validator.validate_agent_error(binary)
|
||||
end
|
||||
|
||||
test "accepts empty error message" do
|
||||
error = %AgentError{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
message: ""
|
||||
}
|
||||
|
||||
binary = AgentError.encode(error)
|
||||
assert {:ok, _} = Validator.validate_agent_error(binary)
|
||||
end
|
||||
|
||||
test "accepts error message at exactly 1000 characters" do
|
||||
error = %AgentError{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
message: String.duplicate("e", 1000)
|
||||
}
|
||||
|
||||
binary = AgentError.encode(error)
|
||||
assert {:ok, _} = Validator.validate_agent_error(binary)
|
||||
end
|
||||
|
||||
test "rejects error message at 1001 characters" do
|
||||
error = %AgentError{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
message: String.duplicate("e", 1001)
|
||||
}
|
||||
|
||||
binary = AgentError.encode(error)
|
||||
assert {:error, {:string_too_long, _}} = Validator.validate_agent_error(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_credential_test_result/1 - system description length" do
|
||||
test "rejects system description exceeding 10_000 characters" do
|
||||
result = %CredentialTestResult{
|
||||
test_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
success: true,
|
||||
error_message: "",
|
||||
system_description: String.duplicate("d", 10_001),
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = CredentialTestResult.encode(result)
|
||||
assert {:error, {:string_too_long, msg}} = Validator.validate_credential_test_result(binary)
|
||||
assert msg =~ "System description"
|
||||
end
|
||||
|
||||
test "accepts system description at exactly 10_000 characters" do
|
||||
result = %CredentialTestResult{
|
||||
test_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
success: true,
|
||||
error_message: "",
|
||||
system_description: String.duplicate("d", 10_000),
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = CredentialTestResult.encode(result)
|
||||
assert {:ok, _} = Validator.validate_credential_test_result(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_credential_test_result/1 - error message length" do
|
||||
test "rejects error message exceeding 1000 characters" do
|
||||
result = %CredentialTestResult{
|
||||
test_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
success: false,
|
||||
error_message: String.duplicate("e", 1001),
|
||||
system_description: "",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = CredentialTestResult.encode(result)
|
||||
assert {:error, {:string_too_long, _}} = Validator.validate_credential_test_result(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_credential_test_result/1 - timestamp" do
|
||||
test "rejects invalid timestamp" do
|
||||
result = %CredentialTestResult{
|
||||
test_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
success: true,
|
||||
error_message: "",
|
||||
system_description: "",
|
||||
timestamp: 9_999_999_999
|
||||
}
|
||||
|
||||
binary = CredentialTestResult.encode(result)
|
||||
assert {:error, {:invalid_timestamp, _}} = Validator.validate_credential_test_result(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_mikrotik_result/1 - edge cases" do
|
||||
test "rejects invalid device_id" do
|
||||
result = %MikrotikResult{
|
||||
device_id: "not-valid",
|
||||
job_id: "job123",
|
||||
sentences: [],
|
||||
error: "",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = MikrotikResult.encode(result)
|
||||
assert {:error, {:invalid_device_id, _}} = Validator.validate_mikrotik_result(binary)
|
||||
end
|
||||
|
||||
test "rejects empty job_id" do
|
||||
result = %MikrotikResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "",
|
||||
sentences: [],
|
||||
error: "",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = MikrotikResult.encode(result)
|
||||
assert {:error, {:invalid_job_id, _}} = Validator.validate_mikrotik_result(binary)
|
||||
end
|
||||
|
||||
test "rejects error message exceeding 1000 characters" do
|
||||
result = %MikrotikResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
sentences: [],
|
||||
error: String.duplicate("e", 1001),
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = MikrotikResult.encode(result)
|
||||
assert {:error, {:string_too_long, _}} = Validator.validate_mikrotik_result(binary)
|
||||
end
|
||||
|
||||
test "rejects invalid timestamp" do
|
||||
result = %MikrotikResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
sentences: [],
|
||||
error: "",
|
||||
timestamp: 9_999_999_999
|
||||
}
|
||||
|
||||
binary = MikrotikResult.encode(result)
|
||||
assert {:error, {:invalid_timestamp, _}} = Validator.validate_mikrotik_result(binary)
|
||||
end
|
||||
|
||||
test "accepts empty sentences list" do
|
||||
result = %MikrotikResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
sentences: [],
|
||||
error: "",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = MikrotikResult.encode(result)
|
||||
assert {:ok, _} = Validator.validate_mikrotik_result(binary)
|
||||
end
|
||||
|
||||
test "accepts sentences at exactly 1000" do
|
||||
sentences = Enum.map(1..1000, fn _ -> %MikrotikSentence{attributes: %{}} end)
|
||||
|
||||
result = %MikrotikResult{
|
||||
device_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
job_id: "job123",
|
||||
sentences: sentences,
|
||||
error: "",
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = MikrotikResult.encode(result)
|
||||
assert {:ok, _} = Validator.validate_mikrotik_result(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - interface stat counter boundaries" do
|
||||
test "rejects negative if_out_errors" do
|
||||
stat = %InterfaceStat{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
if_in_octets: 0,
|
||||
if_out_octets: 0,
|
||||
if_in_errors: 0,
|
||||
if_out_errors: -1,
|
||||
if_in_discards: 0,
|
||||
if_out_discards: 0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:interface_stat, stat}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_counter, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects negative if_in_discards" do
|
||||
stat = %InterfaceStat{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
if_in_octets: 0,
|
||||
if_out_octets: 0,
|
||||
if_in_errors: 0,
|
||||
if_out_errors: 0,
|
||||
if_in_discards: -5,
|
||||
if_out_discards: 0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:interface_stat, stat}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_counter, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects negative if_out_discards" do
|
||||
stat = %InterfaceStat{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
if_in_octets: 0,
|
||||
if_out_octets: 0,
|
||||
if_in_errors: 0,
|
||||
if_out_errors: 0,
|
||||
if_in_discards: 0,
|
||||
if_out_discards: -1,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:interface_stat, stat}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_counter, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects invalid interface UUID" do
|
||||
stat = %InterfaceStat{
|
||||
interface_id: "bad-uuid",
|
||||
if_in_octets: 0,
|
||||
if_out_octets: 0,
|
||||
if_in_errors: 0,
|
||||
if_out_errors: 0,
|
||||
if_in_discards: 0,
|
||||
if_out_discards: 0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:interface_stat, stat}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_interface_id, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - neighbor discovery with remote IPv6" do
|
||||
test "accepts remote_address as IPv6" do
|
||||
discovery = %NeighborDiscovery{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
protocol: "lldp",
|
||||
remote_chassis_id: "00:11:22:33:44:55",
|
||||
remote_system_name: "switch01",
|
||||
remote_system_description: "",
|
||||
remote_platform: "",
|
||||
remote_port_id: "",
|
||||
remote_port_description: "",
|
||||
remote_address: "fe80::1",
|
||||
remote_capabilities: [],
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:neighbor_discovery, discovery}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:ok, _} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
|
||||
test "rejects invalid remote_address IP" do
|
||||
discovery = %NeighborDiscovery{
|
||||
interface_id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
protocol: "lldp",
|
||||
remote_chassis_id: "00:11:22:33:44:55",
|
||||
remote_system_name: "switch01",
|
||||
remote_system_description: "",
|
||||
remote_platform: "",
|
||||
remote_port_id: "",
|
||||
remote_port_description: "",
|
||||
remote_address: "not-an-ip",
|
||||
remote_capabilities: [],
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:neighbor_discovery, discovery}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_ip, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - monitoring check with invalid device_id" do
|
||||
test "rejects monitoring check with non-UUID device_id" do
|
||||
check = %MonitoringCheck{
|
||||
device_id: "not-valid",
|
||||
status: "success",
|
||||
response_time_ms: 1.0,
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
batch = %MetricBatch{
|
||||
metrics: [%Metric{metric_type: {:monitoring_check, check}}]
|
||||
}
|
||||
|
||||
binary = MetricBatch.encode(batch)
|
||||
assert {:error, {:invalid_device_id, _}} = Validator.validate_metric_batch(binary)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_snmp_result/1 - malformed protobuf" do
|
||||
test "rejects malformed protobuf binary" do
|
||||
assert {:error, {:decode_error, _}} = Validator.validate_snmp_result(<<255, 255, 255>>)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_agent_error/1 - malformed protobuf" do
|
||||
test "rejects malformed protobuf binary" do
|
||||
assert {:error, {:decode_error, _}} = Validator.validate_agent_error(<<255, 255, 255>>)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_credential_test_result/1 - malformed protobuf" do
|
||||
test "rejects malformed protobuf binary" do
|
||||
assert {:error, {:decode_error, _}} = Validator.validate_credential_test_result(<<255, 255, 255>>)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_mikrotik_result/1 - malformed protobuf" do
|
||||
test "rejects malformed protobuf binary" do
|
||||
assert {:error, {:decode_error, _}} = Validator.validate_mikrotik_result(<<255, 255, 255>>)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_metric_batch/1 - malformed protobuf" do
|
||||
test "rejects malformed protobuf binary" do
|
||||
assert {:error, {:decode_error, _}} = Validator.validate_metric_batch(<<255, 255, 255>>)
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles empty strings gracefully" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
|
|
@ -533,5 +1358,76 @@ defmodule Towerops.Agent.ValidatorTest do
|
|||
binary = SnmpResult.encode(result)
|
||||
assert {:ok, _} = Validator.validate_snmp_result(binary)
|
||||
end
|
||||
|
||||
test "accepts UUID with uppercase hex characters" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "agent01",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
||||
|
||||
# Test with uppercase UUID in device_id
|
||||
result = %SnmpResult{
|
||||
device_id: "550E8400-E29B-41D4-A716-446655440000",
|
||||
job_type: 1,
|
||||
oid_values: %{},
|
||||
timestamp: 1_700_000_000
|
||||
}
|
||||
|
||||
binary = SnmpResult.encode(result)
|
||||
assert {:ok, _} = Validator.validate_snmp_result(binary)
|
||||
end
|
||||
|
||||
test "handles hostname with hyphens" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "my-agent-01",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:ok, _} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "rejects hostname starting with hyphen" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "-invalid",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "rejects hostname ending with hyphen" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "invalid-",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
|
||||
test "rejects hostname with special characters" do
|
||||
heartbeat = %AgentHeartbeat{
|
||||
version: "1.0.0",
|
||||
hostname: "host_name@bad",
|
||||
uptime_seconds: 100,
|
||||
ip_address: ""
|
||||
}
|
||||
|
||||
binary = AgentHeartbeat.encode(heartbeat)
|
||||
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
773
test/towerops/devices/credential_resolver_test.exs
Normal file
773
test/towerops/devices/credential_resolver_test.exs
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
defmodule Towerops.Devices.CredentialResolverTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Devices.CredentialResolver
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
# Helper to build an org struct with credential fields
|
||||
defp build_org(attrs) do
|
||||
defaults = %Organization{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Test Org",
|
||||
slug: "testorg",
|
||||
snmp_community: "org_public",
|
||||
snmp_version: "2c",
|
||||
snmp_port: 161,
|
||||
snmpv3_username: nil,
|
||||
snmpv3_security_level: nil,
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: nil,
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: nil,
|
||||
mikrotik_username: nil,
|
||||
mikrotik_password: nil,
|
||||
mikrotik_port: 8729,
|
||||
mikrotik_ssh_port: 22,
|
||||
mikrotik_use_ssl: true,
|
||||
mikrotik_enabled: false
|
||||
}
|
||||
|
||||
struct(defaults, attrs)
|
||||
end
|
||||
|
||||
# Helper to build a site struct with credential fields
|
||||
defp build_site(attrs) do
|
||||
defaults = %Site{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Test Site",
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
snmp_community: nil,
|
||||
snmp_version: nil,
|
||||
snmp_port: nil,
|
||||
snmpv3_username: nil,
|
||||
snmpv3_security_level: nil,
|
||||
snmpv3_auth_protocol: nil,
|
||||
snmpv3_auth_password: nil,
|
||||
snmpv3_priv_protocol: nil,
|
||||
snmpv3_priv_password: nil,
|
||||
mikrotik_username: nil,
|
||||
mikrotik_password: nil,
|
||||
mikrotik_port: nil,
|
||||
mikrotik_ssh_port: nil,
|
||||
mikrotik_use_ssl: nil,
|
||||
mikrotik_enabled: nil
|
||||
}
|
||||
|
||||
struct(defaults, attrs)
|
||||
end
|
||||
|
||||
# Helper to build a device changeset with given attrs.
|
||||
# Uses cast so only explicitly provided attrs become changes.
|
||||
# Note: Device schema has defaults for snmp_version ("2c"), snmp_port (161),
|
||||
# snmpv3_credential_source ("site"), mikrotik_credential_source ("site").
|
||||
# get_field returns schema defaults when no change is present.
|
||||
defp device_changeset(attrs \\ %{}) do
|
||||
cast(%Device{}, attrs, [
|
||||
:snmp_community,
|
||||
:snmp_version,
|
||||
:snmp_port,
|
||||
:snmp_community_source,
|
||||
:snmpv3_username,
|
||||
:snmpv3_security_level,
|
||||
:snmpv3_auth_protocol,
|
||||
:snmpv3_auth_password,
|
||||
:snmpv3_priv_protocol,
|
||||
:snmpv3_priv_password,
|
||||
:snmpv3_credential_source,
|
||||
:mikrotik_username,
|
||||
:mikrotik_password,
|
||||
:mikrotik_port,
|
||||
:mikrotik_ssh_port,
|
||||
:mikrotik_use_ssl,
|
||||
:mikrotik_enabled,
|
||||
:mikrotik_credential_source
|
||||
])
|
||||
end
|
||||
|
||||
# Helper to build a device changeset where schema defaults are nil,
|
||||
# allowing the resolver to resolve from hierarchy.
|
||||
# This simulates a "blank slate" device with no preexisting defaults.
|
||||
defp bare_device_changeset(attrs \\ %{}) do
|
||||
cast({%{}, device_types()}, attrs, Map.keys(device_types()))
|
||||
end
|
||||
|
||||
defp device_types do
|
||||
%{
|
||||
snmp_community: :string,
|
||||
snmp_version: :string,
|
||||
snmp_port: :integer,
|
||||
snmp_community_source: :string,
|
||||
snmpv3_username: :string,
|
||||
snmpv3_security_level: :string,
|
||||
snmpv3_auth_protocol: :string,
|
||||
snmpv3_auth_password: :string,
|
||||
snmpv3_priv_protocol: :string,
|
||||
snmpv3_priv_password: :string,
|
||||
snmpv3_credential_source: :string,
|
||||
mikrotik_username: :string,
|
||||
mikrotik_password: :string,
|
||||
mikrotik_port: :integer,
|
||||
mikrotik_ssh_port: :integer,
|
||||
mikrotik_use_ssl: :boolean,
|
||||
mikrotik_enabled: :boolean,
|
||||
mikrotik_credential_source: :string
|
||||
}
|
||||
end
|
||||
|
||||
# ── resolve_snmp_credentials/3 - community resolution ──
|
||||
|
||||
describe "resolve_snmp_credentials/3 community resolution" do
|
||||
test "inherits community from organization when device has nil" do
|
||||
org = build_org(snmp_community: "org_secret")
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_community) == "org_secret"
|
||||
assert get_field(result, :snmp_community_source) == "organization"
|
||||
end
|
||||
|
||||
test "inherits community from organization when device has empty string" do
|
||||
org = build_org(snmp_community: "org_secret")
|
||||
changeset = device_changeset(%{snmp_community: ""})
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_community) == "org_secret"
|
||||
assert get_field(result, :snmp_community_source) == "organization"
|
||||
end
|
||||
|
||||
test "keeps device community when explicitly set" do
|
||||
org = build_org(snmp_community: "org_secret")
|
||||
changeset = device_changeset(%{snmp_community: "device_secret"})
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_community) == "device_secret"
|
||||
assert get_field(result, :snmp_community_source) == "device"
|
||||
end
|
||||
|
||||
test "prefers site community over organization" do
|
||||
org = build_org(snmp_community: "org_secret")
|
||||
site = build_site(snmp_community: "site_secret")
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmp_community) == "site_secret"
|
||||
assert get_field(result, :snmp_community_source) == "site"
|
||||
end
|
||||
|
||||
test "falls back to organization when site community is nil" do
|
||||
org = build_org(snmp_community: "org_secret")
|
||||
site = build_site(snmp_community: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmp_community) == "org_secret"
|
||||
assert get_field(result, :snmp_community_source) == "organization"
|
||||
end
|
||||
|
||||
test "sets source to organization when both site and org community are nil" do
|
||||
org = build_org(snmp_community: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_community) == nil
|
||||
assert get_field(result, :snmp_community_source) == "organization"
|
||||
end
|
||||
|
||||
test "works with nil site" do
|
||||
org = build_org(snmp_community: "org_public")
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_community) == "org_public"
|
||||
assert get_field(result, :snmp_community_source) == "organization"
|
||||
end
|
||||
end
|
||||
|
||||
# ── SNMP version resolution (via resolve_snmp_credentials/3) ──
|
||||
#
|
||||
# Device schema has `snmp_version: "2c"` default. The resolver only resolves
|
||||
# from hierarchy when get_field returns nil, so it keeps the schema default
|
||||
# ("2c") when no explicit version is cast into the changeset.
|
||||
# To test hierarchy resolution, we use bare_device_changeset (no schema defaults).
|
||||
|
||||
describe "resolve_snmp_credentials/3 snmp_version resolution" do
|
||||
test "resolves version from organization when field is nil" do
|
||||
org = build_org(snmp_version: "1")
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_version) == "1"
|
||||
end
|
||||
|
||||
test "defaults to 2c when neither site nor org has version" do
|
||||
org = build_org(snmp_version: nil)
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_version) == "2c"
|
||||
end
|
||||
|
||||
test "keeps device version when explicitly set" do
|
||||
org = build_org(snmp_version: "1")
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_version) == "3"
|
||||
end
|
||||
|
||||
test "keeps schema default version when no explicit change" do
|
||||
org = build_org(snmp_version: "1")
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
# Device schema default is "2c", resolver sees non-nil and keeps it
|
||||
assert get_field(result, :snmp_version) == "2c"
|
||||
end
|
||||
|
||||
test "prefers site version over organization" do
|
||||
org = build_org(snmp_version: "1")
|
||||
site = build_site(snmp_version: "3")
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmp_version) == "3"
|
||||
end
|
||||
|
||||
test "falls back to organization when site version is nil" do
|
||||
org = build_org(snmp_version: "1")
|
||||
site = build_site(snmp_version: nil)
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmp_version) == "1"
|
||||
end
|
||||
end
|
||||
|
||||
# ── SNMP port resolution (via resolve_snmp_credentials/3) ──
|
||||
#
|
||||
# Device schema has `snmp_port: 161` default. Same behavior as version above.
|
||||
|
||||
describe "resolve_snmp_credentials/3 snmp_port resolution" do
|
||||
test "resolves port from organization when field is nil" do
|
||||
org = build_org(snmp_port: 1161)
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_port) == 1161
|
||||
end
|
||||
|
||||
test "defaults to 161 when neither site nor org has port" do
|
||||
org = build_org(snmp_port: nil)
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_port) == 161
|
||||
end
|
||||
|
||||
test "keeps device port when explicitly set" do
|
||||
org = build_org(snmp_port: 1161)
|
||||
changeset = device_changeset(%{snmp_port: 9999})
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmp_port) == 9999
|
||||
end
|
||||
|
||||
test "keeps schema default port when no explicit change" do
|
||||
org = build_org(snmp_port: 1161)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, nil)
|
||||
|
||||
# Device schema default is 161, resolver sees non-nil and keeps it
|
||||
assert get_field(result, :snmp_port) == 161
|
||||
end
|
||||
|
||||
test "prefers site port over organization" do
|
||||
org = build_org(snmp_port: 1161)
|
||||
site = build_site(snmp_port: 2222)
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmp_port) == 2222
|
||||
end
|
||||
|
||||
test "falls back to organization when site port is nil" do
|
||||
org = build_org(snmp_port: 1161)
|
||||
site = build_site(snmp_port: nil)
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmp_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmp_port) == 1161
|
||||
end
|
||||
end
|
||||
|
||||
# ── resolve_snmpv3_credentials/3 ──
|
||||
|
||||
describe "resolve_snmpv3_credentials/3" do
|
||||
test "does nothing when snmp_version is not 3" do
|
||||
org = build_org(snmpv3_username: "v3user")
|
||||
changeset = device_changeset(%{snmp_version: "2c"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
# Changeset is returned unmodified - SNMPv3 fields keep their schema defaults
|
||||
assert get_field(result, :snmpv3_username) == nil
|
||||
# Device schema default for snmpv3_credential_source is "site"
|
||||
assert get_field(result, :snmpv3_credential_source) == "site"
|
||||
end
|
||||
|
||||
test "does nothing when snmp_version is nil (default Device changeset)" do
|
||||
org = build_org(snmpv3_username: "v3user")
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmpv3_username) == nil
|
||||
end
|
||||
|
||||
test "inherits SNMPv3 credentials from organization when device username is nil" do
|
||||
org =
|
||||
build_org(
|
||||
snmpv3_username: "org_v3user",
|
||||
snmpv3_security_level: "authPriv",
|
||||
snmpv3_auth_protocol: "SHA-512",
|
||||
snmpv3_auth_password: "orgauthpass",
|
||||
snmpv3_priv_protocol: "AES-256",
|
||||
snmpv3_priv_password: "orgprivpass"
|
||||
)
|
||||
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmpv3_username) == "org_v3user"
|
||||
assert get_field(result, :snmpv3_security_level) == "authPriv"
|
||||
assert get_field(result, :snmpv3_auth_protocol) == "SHA-512"
|
||||
assert get_field(result, :snmpv3_auth_password) == "orgauthpass"
|
||||
assert get_field(result, :snmpv3_priv_protocol) == "AES-256"
|
||||
assert get_field(result, :snmpv3_priv_password) == "orgprivpass"
|
||||
assert get_field(result, :snmpv3_credential_source) == "organization"
|
||||
end
|
||||
|
||||
test "keeps device SNMPv3 credentials when username is set" do
|
||||
org = build_org(snmpv3_username: "org_v3user")
|
||||
|
||||
changeset =
|
||||
device_changeset(%{snmp_version: "3", snmpv3_username: "device_v3user"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmpv3_username) == "device_v3user"
|
||||
assert get_field(result, :snmpv3_credential_source) == "device"
|
||||
end
|
||||
|
||||
test "prefers site SNMPv3 credentials over organization" do
|
||||
org =
|
||||
build_org(
|
||||
snmpv3_username: "org_v3user",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "orgauthpass"
|
||||
)
|
||||
|
||||
site =
|
||||
build_site(
|
||||
snmpv3_username: "site_v3user",
|
||||
snmpv3_auth_protocol: "SHA-512",
|
||||
snmpv3_auth_password: "siteauthpass"
|
||||
)
|
||||
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmpv3_username) == "site_v3user"
|
||||
assert get_field(result, :snmpv3_auth_protocol) == "SHA-512"
|
||||
assert get_field(result, :snmpv3_auth_password) == "siteauthpass"
|
||||
assert get_field(result, :snmpv3_credential_source) == "site"
|
||||
end
|
||||
|
||||
test "falls back to organization when site SNMPv3 username is nil" do
|
||||
org = build_org(snmpv3_username: "org_v3user")
|
||||
site = build_site(snmpv3_username: nil)
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :snmpv3_username) == "org_v3user"
|
||||
assert get_field(result, :snmpv3_credential_source) == "organization"
|
||||
end
|
||||
|
||||
test "uses default auth_protocol SHA-256 when hierarchy has nil" do
|
||||
org = build_org(snmpv3_username: "org_v3user", snmpv3_auth_protocol: nil)
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmpv3_auth_protocol) == "SHA-256"
|
||||
end
|
||||
|
||||
test "uses default priv_protocol AES when hierarchy has nil" do
|
||||
org = build_org(snmpv3_username: "org_v3user", snmpv3_priv_protocol: nil)
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmpv3_priv_protocol) == "AES"
|
||||
end
|
||||
|
||||
test "resolves individual SNMPv3 fields from mixed site/org sources" do
|
||||
org =
|
||||
build_org(
|
||||
snmpv3_username: "org_v3user",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "orgauthpass",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "orgprivpass"
|
||||
)
|
||||
|
||||
# Site overrides only auth fields, priv fields fall back to org
|
||||
site =
|
||||
build_site(
|
||||
snmpv3_username: "site_v3user",
|
||||
snmpv3_auth_protocol: "SHA-512",
|
||||
snmpv3_auth_password: "siteauthpass",
|
||||
snmpv3_priv_protocol: nil,
|
||||
snmpv3_priv_password: nil
|
||||
)
|
||||
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, site)
|
||||
|
||||
# Username and source come from site (since site username is set)
|
||||
assert get_field(result, :snmpv3_username) == "site_v3user"
|
||||
assert get_field(result, :snmpv3_credential_source) == "site"
|
||||
# Auth fields resolved from site
|
||||
assert get_field(result, :snmpv3_auth_protocol) == "SHA-512"
|
||||
assert get_field(result, :snmpv3_auth_password) == "siteauthpass"
|
||||
# Priv fields fall back to org
|
||||
assert get_field(result, :snmpv3_priv_protocol) == "AES"
|
||||
assert get_field(result, :snmpv3_priv_password) == "orgprivpass"
|
||||
end
|
||||
|
||||
test "sets source to organization when both site and org username are nil" do
|
||||
org = build_org(snmpv3_username: nil)
|
||||
changeset = device_changeset(%{snmp_version: "3"})
|
||||
|
||||
result = CredentialResolver.resolve_snmpv3_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :snmpv3_username) == nil
|
||||
assert get_field(result, :snmpv3_credential_source) == "organization"
|
||||
end
|
||||
end
|
||||
|
||||
# ── resolve_mikrotik_credentials/3 ──
|
||||
|
||||
describe "resolve_mikrotik_credentials/3" do
|
||||
test "inherits MikroTik credentials from organization when device username is nil" do
|
||||
org =
|
||||
build_org(
|
||||
mikrotik_username: "org_admin",
|
||||
mikrotik_password: "orgpass123",
|
||||
mikrotik_port: 8729,
|
||||
mikrotik_ssh_port: 22,
|
||||
mikrotik_use_ssl: true,
|
||||
mikrotik_enabled: true
|
||||
)
|
||||
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_username) == "org_admin"
|
||||
assert get_field(result, :mikrotik_password) == "orgpass123"
|
||||
assert get_field(result, :mikrotik_port) == 8729
|
||||
assert get_field(result, :mikrotik_ssh_port) == 22
|
||||
assert get_field(result, :mikrotik_use_ssl) == true
|
||||
assert get_field(result, :mikrotik_enabled) == true
|
||||
assert get_field(result, :mikrotik_credential_source) == "organization"
|
||||
end
|
||||
|
||||
test "keeps device MikroTik credentials when username is set" do
|
||||
org = build_org(mikrotik_username: "org_admin")
|
||||
|
||||
changeset = device_changeset(%{mikrotik_username: "device_admin"})
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_username) == "device_admin"
|
||||
assert get_field(result, :mikrotik_credential_source) == "device"
|
||||
end
|
||||
|
||||
test "prefers site MikroTik credentials over organization" do
|
||||
org =
|
||||
build_org(
|
||||
mikrotik_username: "org_admin",
|
||||
mikrotik_password: "orgpass",
|
||||
mikrotik_port: 8729
|
||||
)
|
||||
|
||||
site =
|
||||
build_site(
|
||||
mikrotik_username: "site_admin",
|
||||
mikrotik_password: "sitepass",
|
||||
mikrotik_port: 9999
|
||||
)
|
||||
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :mikrotik_username) == "site_admin"
|
||||
assert get_field(result, :mikrotik_password) == "sitepass"
|
||||
assert get_field(result, :mikrotik_port) == 9999
|
||||
assert get_field(result, :mikrotik_credential_source) == "site"
|
||||
end
|
||||
|
||||
test "falls back to organization when site MikroTik username is nil" do
|
||||
org = build_org(mikrotik_username: "org_admin")
|
||||
site = build_site(mikrotik_username: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, site)
|
||||
|
||||
assert get_field(result, :mikrotik_username) == "org_admin"
|
||||
assert get_field(result, :mikrotik_credential_source) == "organization"
|
||||
end
|
||||
|
||||
test "defaults mikrotik_port to 8729 when hierarchy has nil" do
|
||||
org = build_org(mikrotik_username: "org_admin", mikrotik_port: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_port) == 8729
|
||||
end
|
||||
|
||||
test "defaults mikrotik_ssh_port to 22 when hierarchy has nil" do
|
||||
org = build_org(mikrotik_username: "org_admin", mikrotik_ssh_port: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_ssh_port) == 22
|
||||
end
|
||||
|
||||
test "defaults mikrotik_use_ssl to true when hierarchy has nil" do
|
||||
org = build_org(mikrotik_username: "org_admin", mikrotik_use_ssl: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_use_ssl) == true
|
||||
end
|
||||
|
||||
test "respects mikrotik_use_ssl false from hierarchy" do
|
||||
org = build_org(mikrotik_username: "org_admin", mikrotik_use_ssl: false)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_use_ssl) == false
|
||||
end
|
||||
|
||||
test "defaults mikrotik_enabled to false when hierarchy has nil" do
|
||||
org = build_org(mikrotik_username: "org_admin", mikrotik_enabled: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_enabled) == false
|
||||
end
|
||||
|
||||
test "resolves individual MikroTik fields from mixed site/org sources" do
|
||||
org =
|
||||
build_org(
|
||||
mikrotik_username: "org_admin",
|
||||
mikrotik_password: "orgpass",
|
||||
mikrotik_port: 8729,
|
||||
mikrotik_ssh_port: 22,
|
||||
mikrotik_use_ssl: true,
|
||||
mikrotik_enabled: true
|
||||
)
|
||||
|
||||
# Site overrides username/password but not port/ssl
|
||||
site =
|
||||
build_site(
|
||||
mikrotik_username: "site_admin",
|
||||
mikrotik_password: "sitepass",
|
||||
mikrotik_port: nil,
|
||||
mikrotik_ssh_port: nil,
|
||||
mikrotik_use_ssl: nil,
|
||||
mikrotik_enabled: nil
|
||||
)
|
||||
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, site)
|
||||
|
||||
# Username/password resolved from site
|
||||
assert get_field(result, :mikrotik_username) == "site_admin"
|
||||
assert get_field(result, :mikrotik_password) == "sitepass"
|
||||
assert get_field(result, :mikrotik_credential_source) == "site"
|
||||
# Port/ssl fall back to org
|
||||
assert get_field(result, :mikrotik_port) == 8729
|
||||
assert get_field(result, :mikrotik_ssh_port) == 22
|
||||
assert get_field(result, :mikrotik_use_ssl) == true
|
||||
assert get_field(result, :mikrotik_enabled) == true
|
||||
end
|
||||
|
||||
test "sets source to organization when both site and org username are nil" do
|
||||
org = build_org(mikrotik_username: nil)
|
||||
changeset = device_changeset()
|
||||
|
||||
result = CredentialResolver.resolve_mikrotik_credentials(changeset, org, nil)
|
||||
|
||||
assert get_field(result, :mikrotik_username) == nil
|
||||
assert get_field(result, :mikrotik_credential_source) == "organization"
|
||||
end
|
||||
end
|
||||
|
||||
# ── Full pipeline tests ──
|
||||
|
||||
describe "full credential resolution pipeline" do
|
||||
test "resolves all credential types from organization using bare changeset" do
|
||||
org =
|
||||
build_org(
|
||||
snmp_community: "org_community",
|
||||
snmp_version: "3",
|
||||
snmp_port: 1161,
|
||||
snmpv3_username: "org_v3user",
|
||||
snmpv3_security_level: "authPriv",
|
||||
snmpv3_auth_protocol: "SHA-512",
|
||||
snmpv3_auth_password: "orgauthpass",
|
||||
snmpv3_priv_protocol: "AES-256",
|
||||
snmpv3_priv_password: "orgprivpass",
|
||||
mikrotik_username: "org_admin",
|
||||
mikrotik_password: "orgpass"
|
||||
)
|
||||
|
||||
changeset = bare_device_changeset()
|
||||
|
||||
result =
|
||||
changeset
|
||||
|> CredentialResolver.resolve_snmp_credentials(org, nil)
|
||||
|> CredentialResolver.resolve_snmpv3_credentials(org, nil)
|
||||
|> CredentialResolver.resolve_mikrotik_credentials(org, nil)
|
||||
|
||||
# SNMP v2c
|
||||
assert get_field(result, :snmp_community) == "org_community"
|
||||
assert get_field(result, :snmp_community_source) == "organization"
|
||||
assert get_field(result, :snmp_version) == "3"
|
||||
assert get_field(result, :snmp_port) == 1161
|
||||
|
||||
# SNMPv3 (version was resolved to "3" by resolve_snmp_credentials)
|
||||
assert get_field(result, :snmpv3_username) == "org_v3user"
|
||||
assert get_field(result, :snmpv3_credential_source) == "organization"
|
||||
|
||||
# MikroTik
|
||||
assert get_field(result, :mikrotik_username) == "org_admin"
|
||||
assert get_field(result, :mikrotik_credential_source) == "organization"
|
||||
end
|
||||
|
||||
test "device overrides take precedence over all hierarchy values" do
|
||||
org =
|
||||
build_org(
|
||||
snmp_community: "org_community",
|
||||
mikrotik_username: "org_admin"
|
||||
)
|
||||
|
||||
site =
|
||||
build_site(
|
||||
snmp_community: "site_community",
|
||||
mikrotik_username: "site_admin"
|
||||
)
|
||||
|
||||
changeset =
|
||||
device_changeset(%{
|
||||
snmp_community: "device_community",
|
||||
mikrotik_username: "device_admin"
|
||||
})
|
||||
|
||||
result =
|
||||
changeset
|
||||
|> CredentialResolver.resolve_snmp_credentials(org, site)
|
||||
|> CredentialResolver.resolve_mikrotik_credentials(org, site)
|
||||
|
||||
assert get_field(result, :snmp_community) == "device_community"
|
||||
assert get_field(result, :snmp_community_source) == "device"
|
||||
assert get_field(result, :mikrotik_username) == "device_admin"
|
||||
assert get_field(result, :mikrotik_credential_source) == "device"
|
||||
end
|
||||
|
||||
test "site overrides org in full pipeline" do
|
||||
org =
|
||||
build_org(
|
||||
snmp_community: "org_community",
|
||||
mikrotik_username: "org_admin",
|
||||
mikrotik_password: "orgpass"
|
||||
)
|
||||
|
||||
site =
|
||||
build_site(
|
||||
snmp_community: "site_community",
|
||||
mikrotik_username: "site_admin",
|
||||
mikrotik_password: "sitepass"
|
||||
)
|
||||
|
||||
changeset = device_changeset()
|
||||
|
||||
result =
|
||||
changeset
|
||||
|> CredentialResolver.resolve_snmp_credentials(org, site)
|
||||
|> CredentialResolver.resolve_mikrotik_credentials(org, site)
|
||||
|
||||
assert get_field(result, :snmp_community) == "site_community"
|
||||
assert get_field(result, :snmp_community_source) == "site"
|
||||
assert get_field(result, :mikrotik_username) == "site_admin"
|
||||
assert get_field(result, :mikrotik_password) == "sitepass"
|
||||
assert get_field(result, :mikrotik_credential_source) == "site"
|
||||
end
|
||||
|
||||
test "SNMPv3 resolution skipped when version is not 3" do
|
||||
org =
|
||||
build_org(
|
||||
snmpv3_username: "org_v3user",
|
||||
snmpv3_auth_password: "orgauthpass"
|
||||
)
|
||||
|
||||
changeset = device_changeset()
|
||||
|
||||
result =
|
||||
changeset
|
||||
|> CredentialResolver.resolve_snmp_credentials(org, nil)
|
||||
|> CredentialResolver.resolve_snmpv3_credentials(org, nil)
|
||||
|
||||
# Version defaults to "2c" from schema, so SNMPv3 is skipped
|
||||
assert get_field(result, :snmp_version) == "2c"
|
||||
assert get_field(result, :snmpv3_username) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
1157
test/towerops/devices/device_schema_test.exs
Normal file
1157
test/towerops/devices/device_schema_test.exs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,11 @@
|
|||
defmodule Towerops.Devices.FirmwareTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.DevicesFixtures
|
||||
|
||||
alias Towerops.Devices.DeviceFirmwareHistory
|
||||
alias Towerops.Devices.Firmware
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
|
||||
describe "upsert_firmware_release/1" do
|
||||
test "creates a new firmware release" do
|
||||
|
|
@ -119,4 +123,91 @@ defmodule Towerops.Devices.FirmwareTest do
|
|||
assert Firmware.list_firmware_releases() == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_device_firmware_history/2" do
|
||||
test "returns empty list for device with no history" do
|
||||
snmp_device = insert_snmp_device()
|
||||
|
||||
assert Firmware.list_device_firmware_history(snmp_device.id) == []
|
||||
end
|
||||
|
||||
test "returns history in descending order by detected_at" do
|
||||
snmp_device = insert_snmp_device()
|
||||
now = DateTime.utc_now()
|
||||
earlier = DateTime.add(now, -3600, :second)
|
||||
|
||||
insert_firmware_history(snmp_device.id, "7.12.0", "7.13.0", earlier)
|
||||
insert_firmware_history(snmp_device.id, "7.13.0", "7.14.1", now)
|
||||
|
||||
history = Firmware.list_device_firmware_history(snmp_device.id)
|
||||
|
||||
assert length(history) == 2
|
||||
assert Enum.at(history, 0).new_version == "7.14.1"
|
||||
assert Enum.at(history, 1).new_version == "7.13.0"
|
||||
end
|
||||
|
||||
test "respects limit parameter" do
|
||||
snmp_device = insert_snmp_device()
|
||||
now = DateTime.utc_now()
|
||||
|
||||
insert_firmware_history(snmp_device.id, "7.12.0", "7.13.0", DateTime.add(now, -7200, :second))
|
||||
insert_firmware_history(snmp_device.id, "7.13.0", "7.14.0", DateTime.add(now, -3600, :second))
|
||||
insert_firmware_history(snmp_device.id, "7.14.0", "7.14.1", now)
|
||||
|
||||
history = Firmware.list_device_firmware_history(snmp_device.id, 2)
|
||||
|
||||
assert length(history) == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "log_firmware_change/3" do
|
||||
test "creates firmware history record" do
|
||||
snmp_device = insert_snmp_device()
|
||||
|
||||
assert {:ok, history} = Firmware.log_firmware_change(snmp_device.id, "7.12.0", "7.14.1")
|
||||
assert history.snmp_device_id == snmp_device.id
|
||||
assert history.old_version == "7.12.0"
|
||||
assert history.new_version == "7.14.1"
|
||||
assert history.detection_method == "discovery"
|
||||
assert history.detected_at
|
||||
end
|
||||
|
||||
test "broadcasts change via PubSub" do
|
||||
snmp_device = insert_snmp_device()
|
||||
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{snmp_device.id}")
|
||||
|
||||
{:ok, _history} = Firmware.log_firmware_change(snmp_device.id, "7.12.0", "7.14.1")
|
||||
|
||||
assert_receive {:firmware_changed, device_id, "7.12.0", "7.14.1"}
|
||||
assert device_id == snmp_device.id
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_firmware_history(snmp_device_id, old_version, new_version, detected_at) do
|
||||
%DeviceFirmwareHistory{}
|
||||
|> DeviceFirmwareHistory.changeset(%{
|
||||
snmp_device_id: snmp_device_id,
|
||||
old_version: old_version,
|
||||
new_version: new_version,
|
||||
detected_at: detected_at,
|
||||
detection_method: "discovery"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
defp insert_snmp_device do
|
||||
device = device_fixture()
|
||||
|
||||
%SnmpDevice{}
|
||||
|> SnmpDevice.changeset(%{
|
||||
device_id: device.id,
|
||||
sys_descr: "RouterOS v7.13.5",
|
||||
sys_object_id: "1.3.6.1.4.1.14988.1",
|
||||
sys_uptime: 0,
|
||||
sys_name: "test-router",
|
||||
sys_location: "Test Lab"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ defmodule Towerops.JobMonitoring.MetricsTest do
|
|||
|
||||
# Completed jobs (last hour)
|
||||
now = DateTime.utc_now()
|
||||
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "completed",
|
||||
|
|
|
|||
|
|
@ -11,19 +11,23 @@ defmodule Towerops.JobMonitoringTest do
|
|||
device = device_fixture()
|
||||
|
||||
# Create jobs with different attempted_at times
|
||||
older_job = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -60, :second) # 60 seconds ago
|
||||
})
|
||||
older_job =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
# 60 seconds ago
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -60, :second)
|
||||
})
|
||||
|
||||
newer_job = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.utc_now() # Now
|
||||
})
|
||||
newer_job =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
# Now
|
||||
attempted_at: DateTime.utc_now()
|
||||
})
|
||||
|
||||
# Create completed job (should not be returned)
|
||||
oban_job_fixture(%{
|
||||
|
|
@ -74,12 +78,13 @@ defmodule Towerops.JobMonitoringTest do
|
|||
device = device_fixture()
|
||||
|
||||
# Stuck polling job (3 minutes ago)
|
||||
stuck_job = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
|
||||
})
|
||||
stuck_job =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
|
||||
})
|
||||
|
||||
# Recent polling job (30 seconds ago) - not stuck
|
||||
oban_job_fixture(%{
|
||||
|
|
@ -99,12 +104,13 @@ defmodule Towerops.JobMonitoringTest do
|
|||
device = device_fixture()
|
||||
|
||||
# Stuck discovery job (6 minutes ago)
|
||||
stuck_job = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
|
||||
})
|
||||
stuck_job =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
|
||||
})
|
||||
|
||||
# Recent discovery job (3 minutes ago) - not stuck
|
||||
oban_job_fixture(%{
|
||||
|
|
@ -124,20 +130,22 @@ defmodule Towerops.JobMonitoringTest do
|
|||
device = device_fixture()
|
||||
|
||||
# Stuck polling job (3 minutes ago)
|
||||
stuck_poller = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
|
||||
})
|
||||
stuck_poller =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
|
||||
})
|
||||
|
||||
# Stuck discovery job (6 minutes ago - older, should be first)
|
||||
stuck_discovery = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
|
||||
})
|
||||
stuck_discovery =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
|
||||
})
|
||||
|
||||
# Recent jobs that should not be included
|
||||
oban_job_fixture(%{
|
||||
|
|
@ -181,25 +189,27 @@ defmodule Towerops.JobMonitoringTest do
|
|||
device = device_fixture()
|
||||
|
||||
# Polling job at 125 seconds - should be stuck (over 120s threshold)
|
||||
stuck_poller = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -125, :second)
|
||||
})
|
||||
stuck_poller =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DevicePollerWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -125, :second)
|
||||
})
|
||||
|
||||
# Discovery job at 305 seconds - should be stuck (over 300s threshold)
|
||||
stuck_discovery = oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -305, :second)
|
||||
})
|
||||
stuck_discovery =
|
||||
oban_job_fixture(%{
|
||||
worker: "Towerops.Workers.DiscoveryWorker",
|
||||
state: "executing",
|
||||
args: %{"device_id" => device.id},
|
||||
attempted_at: DateTime.add(DateTime.utc_now(), -305, :second)
|
||||
})
|
||||
|
||||
stuck = JobMonitoring.list_stuck_jobs()
|
||||
|
||||
assert length(stuck) == 2
|
||||
assert stuck |> Enum.map(& &1.id) |> Enum.sort() == [stuck_discovery.id, stuck_poller.id] |> Enum.sort()
|
||||
assert stuck |> Enum.map(& &1.id) |> Enum.sort() == Enum.sort([stuck_discovery.id, stuck_poller.id])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
158
test/towerops/mobile_sessions/mobile_session_test.exs
Normal file
158
test/towerops/mobile_sessions/mobile_session_test.exs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
defmodule Towerops.MobileSessions.MobileSessionTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.MobileSessions.MobileSession
|
||||
|
||||
describe "create_changeset/2" do
|
||||
test "valid changeset with just user_id" do
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "auto-generates token when not provided" do
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
token = get_field(changeset, :token)
|
||||
|
||||
assert token
|
||||
assert is_binary(token)
|
||||
assert String.length(token) > 0
|
||||
end
|
||||
|
||||
test "does not override provided token" do
|
||||
attrs = %{user_id: Ecto.UUID.generate(), token: "my-custom-token"}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert get_field(changeset, :token) == "my-custom-token"
|
||||
end
|
||||
|
||||
test "auto-generates expires_at approximately 90 days from now" do
|
||||
now = DateTime.utc_now()
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
expires_at = get_field(changeset, :expires_at)
|
||||
|
||||
assert expires_at
|
||||
|
||||
diff_seconds = DateTime.diff(expires_at, now, :second)
|
||||
expected_seconds = 90 * 24 * 60 * 60
|
||||
assert_in_delta diff_seconds, expected_seconds, 60
|
||||
end
|
||||
|
||||
test "auto-generates last_used_at approximately now" do
|
||||
now = DateTime.utc_now()
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
last_used_at = get_field(changeset, :last_used_at)
|
||||
|
||||
assert last_used_at
|
||||
|
||||
diff_seconds = abs(DateTime.diff(last_used_at, now, :second))
|
||||
assert_in_delta diff_seconds, 0, 60
|
||||
end
|
||||
|
||||
test "does not override provided expires_at" do
|
||||
custom_expires_at =
|
||||
DateTime.utc_now() |> DateTime.add(30, :day) |> DateTime.truncate(:second)
|
||||
|
||||
attrs = %{user_id: Ecto.UUID.generate(), expires_at: custom_expires_at}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert DateTime.compare(get_field(changeset, :expires_at), custom_expires_at) == :eq
|
||||
end
|
||||
|
||||
test "requires user_id" do
|
||||
attrs = %{}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{user_id: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates push_platform accepts apns" do
|
||||
attrs = %{user_id: Ecto.UUID.generate(), push_platform: "apns"}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :push_platform) == "apns"
|
||||
end
|
||||
|
||||
test "validates push_platform accepts fcm" do
|
||||
attrs = %{user_id: Ecto.UUID.generate(), push_platform: "fcm"}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :push_platform) == "fcm"
|
||||
end
|
||||
|
||||
test "validates push_platform accepts nil" do
|
||||
attrs = %{user_id: Ecto.UUID.generate(), push_platform: nil}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "validates push_platform rejects invalid values" do
|
||||
attrs = %{user_id: Ecto.UUID.generate(), push_platform: "invalid"}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{push_platform: ["is invalid"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "accepts device_name, device_os, and app_version" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
device_name: "iPhone 15 Pro",
|
||||
device_os: "iOS 17.2",
|
||||
app_version: "1.0.0"
|
||||
}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :device_name) == "iPhone 15 Pro"
|
||||
assert get_field(changeset, :device_os) == "iOS 17.2"
|
||||
assert get_field(changeset, :app_version) == "1.0.0"
|
||||
end
|
||||
|
||||
test "defaults alerts_enabled to true" do
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = MobileSession.create_changeset(%MobileSession{}, attrs)
|
||||
|
||||
assert get_field(changeset, :alerts_enabled) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "touch_changeset/1" do
|
||||
test "updates last_used_at to approximately now" do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
session = %MobileSession{
|
||||
last_used_at: DateTime.utc_now() |> DateTime.add(-1, :day) |> DateTime.truncate(:second)
|
||||
}
|
||||
|
||||
changeset = MobileSession.touch_changeset(session)
|
||||
last_used_at = get_field(changeset, :last_used_at)
|
||||
|
||||
assert last_used_at
|
||||
|
||||
diff_seconds = abs(DateTime.diff(last_used_at, now, :second))
|
||||
assert_in_delta diff_seconds, 0, 60
|
||||
end
|
||||
end
|
||||
end
|
||||
108
test/towerops/mobile_sessions/qr_login_token_test.exs
Normal file
108
test/towerops/mobile_sessions/qr_login_token_test.exs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
defmodule Towerops.MobileSessions.QRLoginTokenTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.MobileSessions.QRLoginToken
|
||||
|
||||
describe "create_changeset/2" do
|
||||
test "valid changeset with user_id auto-generates token and expires_at" do
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :token)
|
||||
assert get_field(changeset, :expires_at)
|
||||
end
|
||||
|
||||
test "auto-generates token when not provided" do
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
token = get_field(changeset, :token)
|
||||
|
||||
assert token
|
||||
assert is_binary(token)
|
||||
assert String.length(token) > 0
|
||||
end
|
||||
|
||||
test "does not override provided token" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
token: "custom-token"
|
||||
}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
|
||||
assert get_field(changeset, :token) == "custom-token"
|
||||
end
|
||||
|
||||
test "auto-generates expires_at approximately 5 minutes from now" do
|
||||
now = DateTime.utc_now()
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
expires_at = get_field(changeset, :expires_at)
|
||||
|
||||
assert expires_at
|
||||
|
||||
diff_seconds = DateTime.diff(expires_at, now, :second)
|
||||
expected_seconds = 5 * 60
|
||||
assert_in_delta diff_seconds, expected_seconds, 60
|
||||
end
|
||||
|
||||
test "does not override provided expires_at" do
|
||||
custom_expires_at = DateTime.utc_now() |> DateTime.add(1, :day) |> DateTime.truncate(:second)
|
||||
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
expires_at: custom_expires_at
|
||||
}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
|
||||
assert DateTime.compare(get_field(changeset, :expires_at), custom_expires_at) == :eq
|
||||
end
|
||||
|
||||
test "requires user_id" do
|
||||
attrs = %{}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{user_id: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "token is a non-empty string" do
|
||||
attrs = %{user_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = QRLoginToken.create_changeset(%QRLoginToken{}, attrs)
|
||||
token = get_field(changeset, :token)
|
||||
|
||||
assert is_binary(token)
|
||||
assert byte_size(token) > 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "complete_changeset/2" do
|
||||
test "sets completed_at to approximately now" do
|
||||
now = DateTime.utc_now()
|
||||
mobile_session_id = Ecto.UUID.generate()
|
||||
|
||||
changeset = QRLoginToken.complete_changeset(%QRLoginToken{}, mobile_session_id)
|
||||
completed_at = get_field(changeset, :completed_at)
|
||||
|
||||
assert completed_at
|
||||
|
||||
diff_seconds = abs(DateTime.diff(completed_at, now, :second))
|
||||
assert_in_delta diff_seconds, 0, 60
|
||||
end
|
||||
|
||||
test "sets mobile_session_id" do
|
||||
mobile_session_id = Ecto.UUID.generate()
|
||||
|
||||
changeset = QRLoginToken.complete_changeset(%QRLoginToken{}, mobile_session_id)
|
||||
|
||||
assert get_field(changeset, :mobile_session_id) == mobile_session_id
|
||||
end
|
||||
end
|
||||
end
|
||||
415
test/towerops/mobile_sessions_test.exs
Normal file
415
test/towerops/mobile_sessions_test.exs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
defmodule Towerops.MobileSessionsTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.MobileSessions
|
||||
alias Towerops.MobileSessions.MobileSession
|
||||
alias Towerops.MobileSessions.QRLoginToken
|
||||
|
||||
defp valid_session_attrs(user) do
|
||||
%{
|
||||
user_id: user.id,
|
||||
device_name: "iPhone 15 Pro",
|
||||
device_os: "iOS 17.2",
|
||||
app_version: "1.0.0"
|
||||
}
|
||||
end
|
||||
|
||||
defp create_session(user, overrides \\ %{}) do
|
||||
attrs = Map.merge(valid_session_attrs(user), overrides)
|
||||
{:ok, session} = MobileSessions.create_mobile_session(attrs)
|
||||
session
|
||||
end
|
||||
|
||||
describe "create_mobile_session/1" do
|
||||
test "creates session with valid attrs" do
|
||||
user = user_fixture()
|
||||
|
||||
assert {:ok, %MobileSession{} = session} =
|
||||
MobileSessions.create_mobile_session(valid_session_attrs(user))
|
||||
|
||||
assert session.user_id == user.id
|
||||
assert session.device_name == "iPhone 15 Pro"
|
||||
assert session.device_os == "iOS 17.2"
|
||||
assert session.app_version == "1.0.0"
|
||||
assert session.token
|
||||
assert session.expires_at
|
||||
assert session.last_used_at
|
||||
assert session.alerts_enabled == true
|
||||
end
|
||||
|
||||
test "returns error changeset with invalid attrs (missing user_id)" do
|
||||
assert {:error, %Ecto.Changeset{}} =
|
||||
MobileSessions.create_mobile_session(%{device_name: "Test"})
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_session/1" do
|
||||
test "returns session by id" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
assert found = MobileSessions.get_session(session.id)
|
||||
assert found.id == session.id
|
||||
end
|
||||
|
||||
test "returns nil for non-existent id" do
|
||||
assert MobileSessions.get_session(Ecto.UUID.generate()) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_session_by_token/1" do
|
||||
test "returns session for valid token" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
assert found = MobileSessions.get_session_by_token(session.token)
|
||||
assert found.id == session.id
|
||||
end
|
||||
|
||||
test "returns nil for expired session" do
|
||||
user = user_fixture()
|
||||
expired = create_session(user, %{expires_at: DateTime.add(DateTime.utc_now(), -1, :day)})
|
||||
|
||||
assert MobileSessions.get_session_by_token(expired.token) == nil
|
||||
end
|
||||
|
||||
test "returns nil for non-existent token" do
|
||||
assert MobileSessions.get_session_by_token("nonexistent-token") == nil
|
||||
end
|
||||
|
||||
test "returns nil for non-binary input" do
|
||||
assert MobileSessions.get_session_by_token(nil) == nil
|
||||
assert MobileSessions.get_session_by_token(123) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "touch_session/1" do
|
||||
test "updates last_used_at" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
original_last_used_at = session.last_used_at
|
||||
|
||||
# Small sleep to ensure time difference
|
||||
Process.sleep(1000)
|
||||
|
||||
assert {:ok, touched} = MobileSessions.touch_session(session)
|
||||
assert DateTime.compare(touched.last_used_at, original_last_used_at) in [:gt, :eq]
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_user_sessions/1" do
|
||||
test "returns active sessions for user ordered by last_used_at desc" do
|
||||
user = user_fixture()
|
||||
session1 = create_session(user, %{device_name: "Device A"})
|
||||
|
||||
Process.sleep(1000)
|
||||
session2 = create_session(user, %{device_name: "Device B"})
|
||||
|
||||
sessions = MobileSessions.list_user_sessions(user.id)
|
||||
assert length(sessions) == 2
|
||||
assert hd(sessions).id == session2.id
|
||||
assert List.last(sessions).id == session1.id
|
||||
end
|
||||
|
||||
test "excludes expired sessions" do
|
||||
user = user_fixture()
|
||||
_active = create_session(user, %{device_name: "Active"})
|
||||
|
||||
_expired =
|
||||
create_session(user, %{
|
||||
device_name: "Expired",
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -1, :day)
|
||||
})
|
||||
|
||||
sessions = MobileSessions.list_user_sessions(user.id)
|
||||
assert length(sessions) == 1
|
||||
assert hd(sessions).device_name == "Active"
|
||||
end
|
||||
|
||||
test "returns empty list for no sessions" do
|
||||
user = user_fixture()
|
||||
assert MobileSessions.list_user_sessions(user.id) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "revoke_session/1" do
|
||||
test "deletes session by id" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
assert {:ok, %MobileSession{}} = MobileSessions.revoke_session(session.id)
|
||||
assert MobileSessions.get_session(session.id) == nil
|
||||
end
|
||||
|
||||
test "returns error for non-existent id" do
|
||||
assert {:error, :not_found} = MobileSessions.revoke_session(Ecto.UUID.generate())
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_alert_preferences/2" do
|
||||
test "updates alerts_enabled" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
assert {:ok, updated} =
|
||||
MobileSessions.update_alert_preferences(session.id, %{alerts_enabled: false})
|
||||
|
||||
assert updated.alerts_enabled == false
|
||||
end
|
||||
|
||||
test "updates push_token" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
assert {:ok, updated} =
|
||||
MobileSessions.update_alert_preferences(session.id, %{
|
||||
push_token: "device-push-token-abc",
|
||||
push_platform: "apns"
|
||||
})
|
||||
|
||||
assert updated.push_token == "device-push-token-abc"
|
||||
assert updated.push_platform == "apns"
|
||||
end
|
||||
|
||||
test "returns error for non-existent session" do
|
||||
assert {:error, :not_found} =
|
||||
MobileSessions.update_alert_preferences(Ecto.UUID.generate(), %{
|
||||
alerts_enabled: false
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_alert_enabled_devices/1" do
|
||||
test "returns sessions with alerts enabled and push_token set" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
{:ok, _updated} =
|
||||
MobileSessions.update_alert_preferences(session.id, %{
|
||||
alerts_enabled: true,
|
||||
push_token: "token-abc",
|
||||
push_platform: "apns"
|
||||
})
|
||||
|
||||
devices = MobileSessions.list_alert_enabled_devices(user.id)
|
||||
assert length(devices) == 1
|
||||
assert hd(devices).id == session.id
|
||||
end
|
||||
|
||||
test "excludes sessions with alerts disabled" do
|
||||
user = user_fixture()
|
||||
session = create_session(user)
|
||||
|
||||
{:ok, _updated} =
|
||||
MobileSessions.update_alert_preferences(session.id, %{
|
||||
alerts_enabled: false,
|
||||
push_token: "token-abc"
|
||||
})
|
||||
|
||||
assert MobileSessions.list_alert_enabled_devices(user.id) == []
|
||||
end
|
||||
|
||||
test "excludes sessions without push_token" do
|
||||
user = user_fixture()
|
||||
_session = create_session(user, %{alerts_enabled: true})
|
||||
|
||||
assert MobileSessions.list_alert_enabled_devices(user.id) == []
|
||||
end
|
||||
|
||||
test "excludes expired sessions" do
|
||||
user = user_fixture()
|
||||
|
||||
session =
|
||||
create_session(user, %{
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -1, :day)
|
||||
})
|
||||
|
||||
{:ok, _updated} =
|
||||
MobileSessions.update_alert_preferences(session.id, %{
|
||||
alerts_enabled: true,
|
||||
push_token: "token-abc"
|
||||
})
|
||||
|
||||
assert MobileSessions.list_alert_enabled_devices(user.id) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "revoke_all_user_sessions/1" do
|
||||
test "deletes all sessions for user, returns count" do
|
||||
user = user_fixture()
|
||||
_session1 = create_session(user, %{device_name: "Device A"})
|
||||
_session2 = create_session(user, %{device_name: "Device B"})
|
||||
|
||||
assert {:ok, 2} = MobileSessions.revoke_all_user_sessions(user.id)
|
||||
assert MobileSessions.list_user_sessions(user.id) == []
|
||||
end
|
||||
|
||||
test "returns 0 when no sessions" do
|
||||
user = user_fixture()
|
||||
assert {:ok, 0} = MobileSessions.revoke_all_user_sessions(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_expired_sessions/0" do
|
||||
test "deletes expired sessions, returns count" do
|
||||
user = user_fixture()
|
||||
|
||||
_expired1 =
|
||||
create_session(user, %{
|
||||
device_name: "Expired 1",
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -2, :day)
|
||||
})
|
||||
|
||||
_expired2 =
|
||||
create_session(user, %{
|
||||
device_name: "Expired 2",
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -1, :day)
|
||||
})
|
||||
|
||||
assert MobileSessions.delete_expired_sessions() == 2
|
||||
end
|
||||
|
||||
test "does not delete active sessions" do
|
||||
user = user_fixture()
|
||||
active = create_session(user, %{device_name: "Active"})
|
||||
|
||||
_expired =
|
||||
create_session(user, %{
|
||||
device_name: "Expired",
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -1, :day)
|
||||
})
|
||||
|
||||
assert MobileSessions.delete_expired_sessions() == 1
|
||||
assert MobileSessions.get_session(active.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_qr_login_token/1" do
|
||||
test "creates token for user" do
|
||||
user = user_fixture()
|
||||
|
||||
assert {:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
assert qr_token.user_id == user.id
|
||||
assert qr_token.token
|
||||
assert qr_token.expires_at
|
||||
assert qr_token.completed_at == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_qr_login_token/1" do
|
||||
test "returns token for valid token string" do
|
||||
user = user_fixture()
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
assert found = MobileSessions.get_qr_login_token(qr_token.token)
|
||||
assert found.id == qr_token.id
|
||||
end
|
||||
|
||||
test "returns nil for expired token" do
|
||||
user = user_fixture()
|
||||
|
||||
{:ok, qr_token} =
|
||||
%QRLoginToken{}
|
||||
|> QRLoginToken.create_changeset(%{
|
||||
user_id: user.id,
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -1, :minute)
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
assert MobileSessions.get_qr_login_token(qr_token.token) == nil
|
||||
end
|
||||
|
||||
test "returns nil for completed token" do
|
||||
user = user_fixture()
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
# Complete the token via the normal flow so it has a valid mobile_session_id
|
||||
device_attrs = %{device_name: "Test Device", device_os: "iOS", app_version: "1.0"}
|
||||
{:ok, _session} = MobileSessions.complete_qr_login(qr_token.token, device_attrs)
|
||||
|
||||
assert MobileSessions.get_qr_login_token(qr_token.token) == nil
|
||||
end
|
||||
|
||||
test "returns nil for non-binary input" do
|
||||
assert MobileSessions.get_qr_login_token(nil) == nil
|
||||
assert MobileSessions.get_qr_login_token(123) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "complete_qr_login/2" do
|
||||
test "creates mobile session and marks token completed" do
|
||||
user = user_fixture()
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
device_attrs = %{
|
||||
device_name: "iPhone 15",
|
||||
device_os: "iOS 17.2",
|
||||
app_version: "1.0.0"
|
||||
}
|
||||
|
||||
assert {:ok, session} = MobileSessions.complete_qr_login(qr_token.token, device_attrs)
|
||||
assert session.user_id == user.id
|
||||
assert session.device_name == "iPhone 15"
|
||||
|
||||
# Verify QR token is marked completed
|
||||
assert MobileSessions.get_qr_login_token(qr_token.token) == nil
|
||||
end
|
||||
|
||||
test "returns error for invalid token" do
|
||||
assert {:error, :invalid_token} =
|
||||
MobileSessions.complete_qr_login("invalid-token", %{
|
||||
device_name: "Test",
|
||||
device_os: "iOS"
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_qr_login_completed/1" do
|
||||
test "returns mobile session for completed token" do
|
||||
user = user_fixture()
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
device_attrs = %{
|
||||
device_name: "iPhone 15",
|
||||
device_os: "iOS 17.2",
|
||||
app_version: "1.0.0"
|
||||
}
|
||||
|
||||
{:ok, session} = MobileSessions.complete_qr_login(qr_token.token, device_attrs)
|
||||
|
||||
found = MobileSessions.check_qr_login_completed(qr_token.token)
|
||||
assert found.id == session.id
|
||||
end
|
||||
|
||||
test "returns nil for incomplete token" do
|
||||
user = user_fixture()
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
assert MobileSessions.check_qr_login_completed(qr_token.token) == nil
|
||||
end
|
||||
|
||||
test "returns nil for non-binary input" do
|
||||
assert MobileSessions.check_qr_login_completed(nil) == nil
|
||||
assert MobileSessions.check_qr_login_completed(123) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_expired_qr_tokens/0" do
|
||||
test "deletes expired tokens, returns count" do
|
||||
user = user_fixture()
|
||||
|
||||
# Create an expired QR token
|
||||
{:ok, _expired} =
|
||||
%QRLoginToken{}
|
||||
|> QRLoginToken.create_changeset(%{
|
||||
user_id: user.id,
|
||||
expires_at: DateTime.add(DateTime.utc_now(), -10, :minute)
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
assert MobileSessions.delete_expired_qr_tokens() >= 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -391,6 +391,22 @@ defmodule Towerops.MonitoringTest do
|
|||
assert hd(result).response_time_ms == 75
|
||||
end
|
||||
|
||||
test "get_latency_data/1 with default options returns recent successful checks", %{device: device} do
|
||||
base_time = DateTime.utc_now()
|
||||
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 42,
|
||||
checked_at: DateTime.add(base_time, -60, :second)
|
||||
})
|
||||
|
||||
result = Monitoring.get_latency_data(device.id)
|
||||
assert length(result) == 1
|
||||
assert hd(result).response_time_ms == 42
|
||||
end
|
||||
|
||||
test "get_latency_data/2 returns empty list when no checks exist", %{device: device} do
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
result = Monitoring.get_latency_data(device.id, since: since, limit: 100)
|
||||
|
|
@ -425,6 +441,18 @@ defmodule Towerops.MonitoringTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "ping_stub" do
|
||||
alias Towerops.Monitoring.PingStub
|
||||
|
||||
test "ping/1 returns ok tuple" do
|
||||
assert {:ok, 10} = PingStub.ping("192.168.1.1")
|
||||
end
|
||||
|
||||
test "ping/2 returns ok tuple" do
|
||||
assert {:ok, 10} = PingStub.ping("192.168.1.1", 5000)
|
||||
end
|
||||
end
|
||||
|
||||
describe "ping" do
|
||||
alias Towerops.Monitoring.Ping
|
||||
|
||||
|
|
|
|||
321
test/towerops/organizations/organization_test.exs
Normal file
321
test/towerops/organizations/organization_test.exs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
defmodule Towerops.Organizations.OrganizationTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
describe "changeset/2 - basic" do
|
||||
test "valid changeset with just name" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp"})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :name) == "Acme Corp"
|
||||
end
|
||||
|
||||
test "requires name" do
|
||||
changeset = Organization.changeset(%Organization{}, %{})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{name: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates name minimum length of 2" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "A"})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{name: [error]} = errors_on(changeset)
|
||||
assert error =~ "at least"
|
||||
end
|
||||
|
||||
test "validates name maximum length of 100" do
|
||||
long_name = String.duplicate("a", 101)
|
||||
changeset = Organization.changeset(%Organization{}, %{name: long_name})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{name: [error]} = errors_on(changeset)
|
||||
assert error =~ "at most"
|
||||
end
|
||||
|
||||
test "auto-generates slug" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp"})
|
||||
|
||||
assert changeset.valid?
|
||||
slug = get_field(changeset, :slug)
|
||||
assert slug
|
||||
assert is_binary(slug)
|
||||
assert String.length(slug) > 0
|
||||
end
|
||||
|
||||
test "does not override existing slug" do
|
||||
org = %Organization{slug: "existing-slug"}
|
||||
changeset = Organization.changeset(org, %{name: "Acme Corp"})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :slug) == "existing-slug"
|
||||
end
|
||||
|
||||
test "slug is lowercase alphanumeric" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp"})
|
||||
|
||||
slug = get_field(changeset, :slug)
|
||||
assert slug =~ ~r/^[a-z0-9]+$/
|
||||
end
|
||||
|
||||
test "validates subscription_plan inclusion" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", subscription_plan: "premium"})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{subscription_plan: _} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "accepts free subscription plan" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", subscription_plan: "free"})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "defaults snmp_version to 2c" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp"})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :snmp_version) == "2c"
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 - SNMP version validation" do
|
||||
test "accepts version 1" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", snmp_version: "1"})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "accepts version 2c" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", snmp_version: "2c"})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "accepts version 3 with required fields" do
|
||||
attrs = %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser"
|
||||
}
|
||||
|
||||
changeset = Organization.changeset(%Organization{snmpv3_auth_protocol: nil, snmpv3_priv_protocol: nil}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "rejects invalid snmp_version" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", snmp_version: "4"})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmp_version: ["must be 1, 2c, or 3"]} = errors_on(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 - SNMP port validation" do
|
||||
test "rejects port 0" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", snmp_port: 0})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmp_port: _} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "rejects port 65536" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", snmp_port: 65_536})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmp_port: _} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "accepts valid port" do
|
||||
changeset = Organization.changeset(%Organization{}, %{name: "Acme Corp", snmp_port: 161})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 - SNMPv3 validation" do
|
||||
test "requires snmpv3_username when snmp_version is 3" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{snmpv3_auth_protocol: nil, snmpv3_priv_protocol: nil}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmpv3_username: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "derives security_level noAuthNoPriv when no auth protocol" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{snmpv3_auth_protocol: nil, snmpv3_priv_protocol: nil}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: nil
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :snmpv3_security_level) == "noAuthNoPriv"
|
||||
end
|
||||
|
||||
test "derives security_level authNoPriv when auth protocol but no priv" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{snmpv3_priv_protocol: nil}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "longpassword",
|
||||
snmpv3_priv_protocol: nil
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :snmpv3_security_level) == "authNoPriv"
|
||||
end
|
||||
|
||||
test "derives security_level authPriv when both auth and priv protocols" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "longpassword",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "longpassword"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :snmpv3_security_level) == "authPriv"
|
||||
end
|
||||
|
||||
test "requires auth_password when auth_protocol is set" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{snmpv3_priv_protocol: nil}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmpv3_auth_password: ["required when auth protocol is selected"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "requires priv_password when priv_protocol is set" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "longpassword",
|
||||
snmpv3_priv_protocol: "AES"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmpv3_priv_password: ["required when privacy protocol is selected"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "rejects auth_password shorter than 8 characters" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{snmpv3_priv_protocol: nil}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "short"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmpv3_auth_password: ["must be at least 8 characters"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "rejects priv_password shorter than 8 characters" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "longpassword",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "short"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{snmpv3_priv_password: ["must be at least 8 characters"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "accepts valid SNMPv3 config with all fields" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "authpass123",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "privpass123"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :snmpv3_security_level) == "authPriv"
|
||||
assert get_field(changeset, :snmpv3_username) == "snmpuser"
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 - MikroTik validation" do
|
||||
test "requires mikrotik_username when mikrotik_enabled is true" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
mikrotik_enabled: true
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{mikrotik_username: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "does not require mikrotik_username when mikrotik_enabled is false" do
|
||||
changeset =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
mikrotik_enabled: false
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "validates mikrotik_port range" do
|
||||
changeset_zero =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
mikrotik_port: 0
|
||||
})
|
||||
|
||||
refute changeset_zero.valid?
|
||||
assert %{mikrotik_port: _} = errors_on(changeset_zero)
|
||||
|
||||
changeset_too_high =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
mikrotik_port: 65_536
|
||||
})
|
||||
|
||||
refute changeset_too_high.valid?
|
||||
assert %{mikrotik_port: _} = errors_on(changeset_too_high)
|
||||
|
||||
changeset_valid =
|
||||
Organization.changeset(%Organization{}, %{
|
||||
name: "Acme Corp",
|
||||
mikrotik_port: 8729
|
||||
})
|
||||
|
||||
assert changeset_valid.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -782,6 +782,46 @@ defmodule Towerops.OrganizationsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "get_default_membership/1" do
|
||||
test "returns the default membership for a user" do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Default Org"}, user.id)
|
||||
|
||||
membership = Organizations.get_default_membership(user.id)
|
||||
assert membership.organization_id == organization.id
|
||||
assert membership.is_default == true
|
||||
assert membership.organization.id == organization.id
|
||||
end
|
||||
|
||||
test "returns nil when user has no default membership" do
|
||||
user = user_fixture()
|
||||
assert Organizations.get_default_membership(user.id) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "set_default_organization/2" do
|
||||
test "sets an organization as the user's default" do
|
||||
user = user_fixture()
|
||||
{:ok, _org1} = Organizations.create_organization(%{name: "Org 1"}, user.id)
|
||||
{:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, user.id, bypass_limits: true)
|
||||
|
||||
assert {:ok, returned_org} = Organizations.set_default_organization(user.id, org2.id)
|
||||
assert returned_org.id == org2.id
|
||||
|
||||
# Verify org2 is now default
|
||||
membership = Organizations.get_default_membership(user.id)
|
||||
assert membership.organization_id == org2.id
|
||||
end
|
||||
|
||||
test "returns error when user has no access to organization" do
|
||||
user1 = user_fixture()
|
||||
user2 = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user1.id)
|
||||
|
||||
assert {:error, :not_found} = Organizations.set_default_organization(user2.id, organization.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "accept_invitation/2 error handling" do
|
||||
test "returns error when membership creation fails" do
|
||||
owner = user_fixture()
|
||||
|
|
|
|||
|
|
@ -151,4 +151,213 @@ defmodule Towerops.Sites.SiteTest do
|
|||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 SNMPv3 validation" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{organization: organization}
|
||||
end
|
||||
|
||||
test "valid SNMPv3 config with all fields", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "authpass1234",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "privpass1234"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "derives noAuthNoPriv when no auth protocol", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert Ecto.Changeset.get_field(changeset, :snmpv3_security_level) == "noAuthNoPriv"
|
||||
end
|
||||
|
||||
test "derives authNoPriv when auth protocol but no priv", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "authpass1234"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert Ecto.Changeset.get_field(changeset, :snmpv3_security_level) == "authNoPriv"
|
||||
end
|
||||
|
||||
test "derives authPriv when both auth and priv protocols", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "authpass1234",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "privpass1234"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert Ecto.Changeset.get_field(changeset, :snmpv3_security_level) == "authPriv"
|
||||
end
|
||||
|
||||
test "requires auth_password when auth_protocol set", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "required when auth protocol is selected" in errors_on(changeset).snmpv3_auth_password
|
||||
end
|
||||
|
||||
test "requires priv_password when priv_protocol set", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "authpass1234",
|
||||
snmpv3_priv_protocol: "AES"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
|
||||
assert "required when privacy protocol is selected" in errors_on(changeset).snmpv3_priv_password
|
||||
end
|
||||
|
||||
test "rejects auth_password shorter than 8 chars", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "short"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "must be at least 8 characters" in errors_on(changeset).snmpv3_auth_password
|
||||
end
|
||||
|
||||
test "rejects priv_password shorter than 8 chars", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMPv3 Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "snmpuser",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "authpass1234",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "short"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "must be at least 8 characters" in errors_on(changeset).snmpv3_priv_password
|
||||
end
|
||||
|
||||
test "accepts valid SNMP versions", %{organization: organization} do
|
||||
for version <- ["1", "2c", "3"] do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMP Site",
|
||||
organization_id: organization.id,
|
||||
snmp_version: version
|
||||
})
|
||||
|
||||
assert changeset.valid?, "expected version #{version} to be valid"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 MikroTik validation" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{organization: organization}
|
||||
end
|
||||
|
||||
test "requires mikrotik_username when enabled", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "MikroTik Site",
|
||||
organization_id: organization.id,
|
||||
mikrotik_enabled: true
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).mikrotik_username
|
||||
end
|
||||
|
||||
test "does not require username when not enabled", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "MikroTik Site",
|
||||
organization_id: organization.id,
|
||||
mikrotik_enabled: false
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "validates mikrotik_port range", %{organization: organization} do
|
||||
changeset_zero =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "MikroTik Site",
|
||||
organization_id: organization.id,
|
||||
mikrotik_port: 0
|
||||
})
|
||||
|
||||
refute changeset_zero.valid?
|
||||
assert errors_on(changeset_zero).mikrotik_port != []
|
||||
|
||||
changeset_too_high =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "MikroTik Site",
|
||||
organization_id: organization.id,
|
||||
mikrotik_port: 65_536
|
||||
})
|
||||
|
||||
refute changeset_too_high.valid?
|
||||
assert errors_on(changeset_too_high).mikrotik_port != []
|
||||
end
|
||||
|
||||
test "accepts valid SNMP port", %{organization: organization} do
|
||||
changeset =
|
||||
Site.changeset(%Site{}, %{
|
||||
name: "SNMP Port Site",
|
||||
organization_id: organization.id,
|
||||
snmp_port: 161
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
992
test/towerops/snmp/profiles/dynamic_test.exs
Normal file
992
test/towerops/snmp/profiles/dynamic_test.exs
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
defmodule Towerops.Snmp.Profiles.DynamicTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.Profiles.Dynamic
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
@client_opts [
|
||||
ip: "192.168.1.1",
|
||||
community: "public",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 5000
|
||||
]
|
||||
|
||||
# A minimal profile map with no device_oids or sensor_oids
|
||||
defp base_profile(overrides \\ %{}) do
|
||||
Map.merge(
|
||||
%{
|
||||
name: "test_device",
|
||||
vendor: "TestVendor",
|
||||
type: "router",
|
||||
device_oids: %{},
|
||||
sensor_oids: [],
|
||||
table_sensor_oids: [],
|
||||
processor_oids: [],
|
||||
count_sensor_oids: [],
|
||||
state_sensor_oids: []
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
defp base_system_info(overrides \\ %{}) do
|
||||
Map.merge(
|
||||
%{
|
||||
sys_descr: "Test Device v1.0",
|
||||
sys_name: "test-device-01",
|
||||
sys_object_id: "1.3.6.1.4.1.99999.1"
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
describe "identify_device/3" do
|
||||
test "returns basic device info when profile has no device_oids" do
|
||||
profile = base_profile()
|
||||
system_info = base_system_info()
|
||||
|
||||
# No SNMP calls needed when device_oids is empty, but
|
||||
# Base.discover_sensors is not called by identify_device so no walks needed.
|
||||
# However, vendor detection calls may happen. Stub everything to fail gracefully.
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.manufacturer == "TestVendor"
|
||||
assert result.sys_descr == "Test Device v1.0"
|
||||
# model falls through to profile_based_model_fallback -> "Router Device"
|
||||
# since vendor detection returns nil and no regex
|
||||
assert result.model == "Router Device"
|
||||
end
|
||||
|
||||
test "uses device_oids to fetch hardware, firmware, and serial from SNMP" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
device_oids: %{
|
||||
hardware: "1.3.6.1.4.1.99999.1.1.0",
|
||||
firmware_version: "1.3.6.1.4.1.99999.1.2.0",
|
||||
serial_number: "1.3.6.1.4.1.99999.1.3.0"
|
||||
}
|
||||
})
|
||||
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.1.1.0" -> {:ok, {:octet_string, "Model X100"}}
|
||||
"1.3.6.1.4.1.99999.1.2.0" -> {:ok, {:octet_string, "2.5.1"}}
|
||||
"1.3.6.1.4.1.99999.1.3.0" -> {:ok, {:octet_string, "SN-ABC123"}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.manufacturer == "TestVendor"
|
||||
assert result.model == "Model X100"
|
||||
assert result.firmware_version == "2.5.1"
|
||||
assert result.serial_number == "SN-ABC123"
|
||||
end
|
||||
|
||||
test "falls back to sysDescr when all model detection methods fail" do
|
||||
# Profile with no type to prevent profile_based_model_fallback
|
||||
profile = base_profile(%{type: nil, vendor: nil})
|
||||
system_info = base_system_info(%{sys_descr: "Generic Router 3000"})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.model == "Generic Router 3000"
|
||||
end
|
||||
|
||||
test "extracts hardware from sysDescr using hardware_regex" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
hardware_regex: ~r/^(?<hardware>[\w-]+)\s+Software/
|
||||
})
|
||||
|
||||
system_info = base_system_info(%{sys_descr: "CX-5000 Software Version 4.2"})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.model == "CX-5000"
|
||||
end
|
||||
|
||||
test "hardware_regex returns nil when no match" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
hardware_regex: ~r/^(?<hardware>RouterOS\s+\w+)/
|
||||
})
|
||||
|
||||
system_info = base_system_info(%{sys_descr: "Totally different device"})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
# Falls through to profile_based_model_fallback -> "Router Device"
|
||||
assert result.model == "Router Device"
|
||||
end
|
||||
|
||||
test "profile_based_model_fallback uses profile type" do
|
||||
profile = base_profile(%{type: "wireless", vendor: "Acme"})
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.model == "Wireless Device"
|
||||
assert result.manufacturer == "Acme"
|
||||
end
|
||||
|
||||
test "preserves system_info fields in result" do
|
||||
profile = base_profile()
|
||||
system_info = base_system_info(%{sys_name: "core-router", sys_contact: "noc@example.com"})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.sys_name == "core-router"
|
||||
assert result.sys_contact == "noc@example.com"
|
||||
end
|
||||
|
||||
test "sets latitude and longitude from device_oids" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
device_oids: %{
|
||||
latitude: "1.3.6.1.4.1.99999.1.10.0",
|
||||
longitude: "1.3.6.1.4.1.99999.1.11.0"
|
||||
}
|
||||
})
|
||||
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.1.10.0" -> {:ok, {:octet_string, "40.7128"}}
|
||||
"1.3.6.1.4.1.99999.1.11.0" -> {:ok, {:octet_string, "-74.0060"}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.latitude == "40.7128"
|
||||
assert result.longitude == "-74.0060"
|
||||
end
|
||||
end
|
||||
|
||||
describe "identify_device/3 firmware formatting" do
|
||||
test "uses raw firmware_version from device_oids when available" do
|
||||
# When firmware_version is fetched via device_oids, the raw value is used
|
||||
# directly (short-circuits before format_firmware_version is called)
|
||||
profile =
|
||||
base_profile(%{
|
||||
vendor: "MikroTik",
|
||||
device_oids: %{
|
||||
firmware_version: "1.3.6.1.4.1.99999.1.2.0"
|
||||
}
|
||||
})
|
||||
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.1.2.0" -> {:ok, {:octet_string, "7.15.3"}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
# Raw value is used - format_firmware_version is not reached
|
||||
assert result.firmware_version == "7.15.3"
|
||||
end
|
||||
|
||||
test "firmware_version is nil when no device_oid and no vendor detection" do
|
||||
profile = base_profile(%{vendor: "TestVendor"})
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.firmware_version == nil
|
||||
end
|
||||
|
||||
test "returns raw firmware for generic vendor" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
device_oids: %{
|
||||
firmware_version: "1.3.6.1.4.1.99999.1.2.0"
|
||||
}
|
||||
})
|
||||
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.1.2.0" -> {:ok, {:octet_string, "4.2.1-build100"}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
assert result.firmware_version == "4.2.1-build100"
|
||||
end
|
||||
|
||||
test "MikroTik device_oids with firmware and license populates device_data" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
vendor: "MikroTik",
|
||||
device_oids: %{
|
||||
firmware_version: "1.3.6.1.4.1.99999.1.2.0",
|
||||
license_version: "1.3.6.1.4.1.99999.1.4.0"
|
||||
}
|
||||
})
|
||||
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.1.2.0" -> {:ok, {:octet_string, "7.15.3"}}
|
||||
"1.3.6.1.4.1.99999.1.4.0" -> {:ok, {:octet_string, "6"}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
# Raw firmware_version from device_oids is used directly
|
||||
assert result.firmware_version == "7.15.3"
|
||||
# License version is also in the device_data merge
|
||||
assert result.license_version == "6"
|
||||
end
|
||||
|
||||
test "Ubiquiti device with raw firmware version" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
vendor: "Ubiquiti AirOS",
|
||||
device_oids: %{
|
||||
firmware_version: "1.3.6.1.4.1.99999.1.2.0"
|
||||
}
|
||||
})
|
||||
|
||||
system_info = base_system_info()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.1.2.0" -> {:ok, {:octet_string, "XW.v8.7.21"}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
result = Dynamic.identify_device(profile, @client_opts, system_info)
|
||||
|
||||
# Raw value from device_oids is used directly
|
||||
assert result.firmware_version == "XW.v8.7.21"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_sensors/2" do
|
||||
test "returns base sensors when profile has no sensor definitions" do
|
||||
profile = base_profile()
|
||||
|
||||
# Base.discover_sensors will walk ENTITY-SENSOR-MIB
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
assert sensors == []
|
||||
end
|
||||
|
||||
test "discovers scalar sensors from profile sensor_oids" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
sensor_oids: [
|
||||
%{
|
||||
mib_name: "1.3.6.1.4.1.99999.2.1.0",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "CPU Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 10
|
||||
},
|
||||
%{
|
||||
mib_name: "1.3.6.1.4.1.99999.2.2.0",
|
||||
sensor_type: "voltage",
|
||||
sensor_descr: "Main Power",
|
||||
sensor_unit: "V",
|
||||
sensor_divisor: 1000
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.2.1.0" -> {:ok, {:integer, 450}}
|
||||
"1.3.6.1.4.1.99999.2.2.0" -> {:ok, {:integer, 12_050}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
# Base sensor and table walks return empty
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
temp = Enum.find(sensors, &(&1.sensor_type == "temperature"))
|
||||
assert temp.sensor_descr == "CPU Temp"
|
||||
assert temp.sensor_unit == "C"
|
||||
assert temp.sensor_divisor == 10
|
||||
assert temp.last_value == 450
|
||||
|
||||
voltage = Enum.find(sensors, &(&1.sensor_type == "voltage"))
|
||||
assert voltage.sensor_descr == "Main Power"
|
||||
assert voltage.sensor_unit == "V"
|
||||
end
|
||||
|
||||
test "skips scalar sensors with zero or non-integer values" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
sensor_oids: [
|
||||
%{
|
||||
mib_name: "1.3.6.1.4.1.99999.2.1.0",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "CPU Temp",
|
||||
sensor_unit: "C"
|
||||
},
|
||||
%{
|
||||
mib_name: "1.3.6.1.4.1.99999.2.2.0",
|
||||
sensor_type: "voltage",
|
||||
sensor_descr: "Bad Sensor"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
# This one returns 0 - should be skipped (value > 0 check)
|
||||
"1.3.6.1.4.1.99999.2.1.0" -> {:ok, {:integer, 0}}
|
||||
# This one errors - should be skipped
|
||||
"1.3.6.1.4.1.99999.2.2.0" -> {:error, :no_such_object}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
# Both should be filtered out
|
||||
assert Enum.empty?(sensors)
|
||||
end
|
||||
|
||||
test "discovers table-based sensors via walk" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp Sensor",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 10,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 450}},
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.2", value: {:integer, 520}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
temp_sensors = Enum.filter(sensors, &(&1.sensor_type == "temperature"))
|
||||
assert length(temp_sensors) == 2
|
||||
|
||||
first = Enum.find(temp_sensors, &(&1.sensor_index == "temperature_1"))
|
||||
assert first.last_value == 450
|
||||
assert first.sensor_unit == "C"
|
||||
assert first.sensor_divisor == 10
|
||||
assert first.sensor_descr == "Temp sensor"
|
||||
|
||||
second = Enum.find(temp_sensors, &(&1.sensor_index == "temperature_2"))
|
||||
assert second.last_value == 520
|
||||
assert second.sensor_descr == "Temp sensor 2"
|
||||
end
|
||||
|
||||
test "discovers table sensors with description OID walk" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
descr_oid: "1.3.6.1.4.1.99999.3.2",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp Sensor",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}},
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.2", value: {:integer, 55}}
|
||||
]}
|
||||
|
||||
"1.3.6.1.4.1.99999.3.2" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.2.1", value: {:octet_string, "Board Temp"}},
|
||||
%{oid: "1.3.6.1.4.1.99999.3.2.2", value: {:octet_string, "CPU Temp"}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
temp_sensors = Enum.filter(sensors, &(&1.sensor_type == "temperature"))
|
||||
assert length(temp_sensors) == 2
|
||||
|
||||
# Descriptions come from the descr_oid walk results
|
||||
descrs = temp_sensors |> Enum.map(& &1.sensor_descr) |> Enum.sort()
|
||||
assert descrs == ["Board Temp", "CPU Temp"]
|
||||
end
|
||||
|
||||
test "discovers state sensors with state mapping" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
state_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.4.1",
|
||||
oid_name: "gps_status",
|
||||
sensor_type: "state",
|
||||
sensor_descr: "GPS Status",
|
||||
states: %{0 => "No Fix", 1 => "2D Fix", 2 => "3D Fix"}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.4.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.4.1.0", value: {:integer, 2}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
state_sensors = Enum.filter(sensors, &(&1.sensor_type == "state"))
|
||||
assert length(state_sensors) == 1
|
||||
|
||||
gps = hd(state_sensors)
|
||||
assert gps.sensor_index == "gps_status_1"
|
||||
assert gps.state_descr == "3D Fix"
|
||||
assert gps.last_value == 2
|
||||
assert gps.metadata["states"]["2"] == "3D Fix"
|
||||
assert gps.metadata["states"]["0"] == "No Fix"
|
||||
end
|
||||
|
||||
test "state sensor uses fallback description for unknown state values" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
state_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.4.1",
|
||||
oid_name: "link_status",
|
||||
sensor_type: "state",
|
||||
sensor_descr: "Link Status",
|
||||
states: %{0 => "Down", 1 => "Up"}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.4.1" ->
|
||||
{:ok,
|
||||
[
|
||||
# State 99 is not in the states map
|
||||
%{oid: "1.3.6.1.4.1.99999.4.1.0", value: {:integer, 99}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
state_sensor = Enum.find(sensors, &(&1.sensor_type == "state"))
|
||||
assert state_sensor.state_descr == "State 99"
|
||||
end
|
||||
|
||||
test "skips non-integer values in table walk results" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}},
|
||||
# String value should be filtered out
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.2", value: {:octet_string, "not a number"}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
temp_sensors = Enum.filter(sensors, &(&1.sensor_type == "temperature"))
|
||||
assert length(temp_sensors) == 1
|
||||
assert hd(temp_sensors).last_value == 42
|
||||
end
|
||||
|
||||
test "handles empty walk results for table sensors" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
assert sensors == []
|
||||
end
|
||||
|
||||
test "handles walk errors for table sensors" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
assert sensors == []
|
||||
end
|
||||
|
||||
test "deduplicates sensors by OID, preferring non-MIB descriptions" do
|
||||
# This tests deduplicate_by_oid/1 through discover_sensors.
|
||||
# We need two sources that produce sensors with the same OID:
|
||||
# table_sensor_oids and the base ENTITY-SENSOR-MIB.
|
||||
# We'll use table_sensor_oids for a sensor with a proper name,
|
||||
# and count_sensor_oids for a duplicate with MIB symbolic name.
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Board Temperature",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
],
|
||||
count_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "count",
|
||||
sensor_descr: "VENDOR-MIB::sensorTemp",
|
||||
sensor_unit: "",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
# Should be deduplicated - one sensor per OID
|
||||
sensors_at_oid =
|
||||
Enum.filter(sensors, &(&1.sensor_oid == "1.3.6.1.4.1.99999.3.1.1"))
|
||||
|
||||
assert length(sensors_at_oid) == 1
|
||||
|
||||
# Should prefer the one without MIB symbolic name in description
|
||||
sensor = hd(sensors_at_oid)
|
||||
refute String.contains?(sensor.sensor_descr, "::")
|
||||
end
|
||||
|
||||
test "discovers processor_oids as table sensors" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
processor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.5.1",
|
||||
sensor_type: "processor",
|
||||
sensor_descr: "CPU Load",
|
||||
sensor_unit: "%",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.5.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.5.1.1", value: {:integer, 25}},
|
||||
%{oid: "1.3.6.1.4.1.99999.5.1.2", value: {:integer, 40}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
processors = Enum.filter(sensors, &(&1.sensor_type == "processor"))
|
||||
assert length(processors) == 2
|
||||
assert Enum.any?(processors, &(&1.last_value == 25))
|
||||
assert Enum.any?(processors, &(&1.last_value == 40))
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_sensors/2 sensor description building" do
|
||||
test "uses sensor_type when description contains MIB symbolic name" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "MIKROTIK-MIB::mtxrOpticalName",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
sensor = Enum.find(sensors, &(&1.sensor_type == "temperature"))
|
||||
# Description should fall back to sensor_type, not use MIB name
|
||||
assert sensor.sensor_descr == "Temperature"
|
||||
refute String.contains?(sensor.sensor_descr, "::")
|
||||
end
|
||||
|
||||
test "uses oid_name for description when sensor_descr is nil" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
oid_name: "boardTemp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
sensor = Enum.find(sensors, &(&1.sensor_type == "temperature"))
|
||||
assert sensor.sensor_descr == "Boardtemp"
|
||||
end
|
||||
|
||||
test "does not append index when only one sensor instance exists" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "CPU Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
sensor = Enum.find(sensors, &(&1.sensor_type == "temperature"))
|
||||
# Single instance should not have index appended
|
||||
assert sensor.sensor_descr == "Cpu temp"
|
||||
end
|
||||
|
||||
test "appends index for multiple sensor instances" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1,
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}},
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.2", value: {:integer, 55}},
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.3", value: {:integer, 38}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
temp_sensors =
|
||||
sensors
|
||||
|> Enum.filter(&(&1.sensor_type == "temperature"))
|
||||
|> Enum.sort_by(& &1.sensor_index)
|
||||
|
||||
assert length(temp_sensors) == 3
|
||||
# First one has no index suffix
|
||||
assert Enum.at(temp_sensors, 0).sensor_descr == "Temp"
|
||||
# Second and third have index suffix
|
||||
assert Enum.at(temp_sensors, 1).sensor_descr == "Temp 2"
|
||||
assert Enum.at(temp_sensors, 2).sensor_descr == "Temp 3"
|
||||
end
|
||||
|
||||
test "uses default sensor_divisor of 1 when not specified" do
|
||||
profile =
|
||||
base_profile(%{
|
||||
table_sensor_oids: [
|
||||
%{
|
||||
base_oid: "1.3.6.1.4.1.99999.3.1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp",
|
||||
descr_oid: nil
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.99999.3.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.99999.3.1.1", value: {:integer, 42}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_sensors(profile, @client_opts)
|
||||
|
||||
sensor = hd(sensors)
|
||||
assert sensor.sensor_divisor == 1
|
||||
assert sensor.sensor_unit == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_wireless_sensors/2" do
|
||||
test "returns empty list for unknown profile" do
|
||||
profile = base_profile(%{name: "nonexistent_vendor_xyz"})
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_wireless_sensors(profile, @client_opts)
|
||||
assert sensors == []
|
||||
end
|
||||
|
||||
test "returns empty list when profile has no name key" do
|
||||
profile = %{vendor: "TestVendor"}
|
||||
|
||||
assert {:ok, sensors} = Dynamic.discover_wireless_sensors(profile, @client_opts)
|
||||
assert sensors == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_interfaces/2" do
|
||||
test "delegates to Base.discover_interfaces" do
|
||||
# Mock ifIndex walk - empty means no interfaces
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
profile = base_profile()
|
||||
|
||||
assert {:ok, interfaces} = Dynamic.discover_interfaces(profile, @client_opts)
|
||||
assert interfaces == []
|
||||
end
|
||||
end
|
||||
end
|
||||
354
test/towerops/snmp/profiles/vendors/vendor_test.exs
vendored
Normal file
354
test/towerops/snmp/profiles/vendors/vendor_test.exs
vendored
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
defmodule Towerops.Snmp.Profiles.Vendors.VendorTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.Profiles.Vendors.Vendor
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
@client_opts [
|
||||
ip: "192.168.1.1",
|
||||
community: "public",
|
||||
version: "2c",
|
||||
port: 161,
|
||||
timeout: 5000
|
||||
]
|
||||
|
||||
@sample_sensor_def %{
|
||||
oid: "1.3.6.1.4.1.17713.21.1.2.1.0",
|
||||
sensor_type: "rssi",
|
||||
sensor_descr: "RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
describe "fetch_sensors/2" do
|
||||
test "returns sensors for all defs that respond with integer values" do
|
||||
defs = [
|
||||
%{
|
||||
oid: "1.3.6.1.4.1.100.1.0",
|
||||
sensor_type: "rssi",
|
||||
sensor_descr: "RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
},
|
||||
%{
|
||||
oid: "1.3.6.1.4.1.100.2.0",
|
||||
sensor_type: "snr",
|
||||
sensor_descr: "SNR",
|
||||
sensor_unit: "dB",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, 2, fn _, _oid, _ -> {:ok, -65} end)
|
||||
|
||||
sensors = Vendor.fetch_sensors(defs, @client_opts)
|
||||
|
||||
assert length(sensors) == 2
|
||||
assert Enum.all?(sensors, &(&1.last_value == -65))
|
||||
end
|
||||
|
||||
test "filters out sensors that fail to respond" do
|
||||
defs = [
|
||||
%{
|
||||
oid: "1.3.6.1.4.1.100.1.0",
|
||||
sensor_type: "rssi",
|
||||
sensor_descr: "RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
},
|
||||
%{
|
||||
oid: "1.3.6.1.4.1.100.2.0",
|
||||
sensor_type: "snr",
|
||||
sensor_descr: "SNR",
|
||||
sensor_unit: "dB",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.100.1.0", _ -> {:ok, -65} end)
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.100.2.0", _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Vendor.fetch_sensors(defs, @client_opts)
|
||||
|
||||
assert length(sensors) == 1
|
||||
assert hd(sensors).sensor_type == "rssi"
|
||||
end
|
||||
|
||||
test "returns empty list when all sensors fail" do
|
||||
defs = [
|
||||
%{
|
||||
oid: "1.3.6.1.4.1.100.1.0",
|
||||
sensor_type: "rssi",
|
||||
sensor_descr: "RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
]
|
||||
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert Vendor.fetch_sensors(defs, @client_opts) == []
|
||||
end
|
||||
|
||||
test "returns empty list for empty defs" do
|
||||
assert Vendor.fetch_sensors([], @client_opts) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_sensor_value/2" do
|
||||
test "returns sensor map with correct fields when SNMP returns integer" do
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.17713.21.1.2.1.0", _ -> {:ok, -65} end)
|
||||
|
||||
sensor = Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts)
|
||||
|
||||
assert sensor.sensor_type == "rssi"
|
||||
assert sensor.sensor_oid == "1.3.6.1.4.1.17713.21.1.2.1.0"
|
||||
assert sensor.sensor_descr == "RSSI"
|
||||
assert sensor.sensor_unit == "dBm"
|
||||
assert sensor.sensor_divisor == 1
|
||||
assert sensor.last_value == -65
|
||||
end
|
||||
|
||||
test "generates sensor_index from description slug and OID suffix" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:ok, 42} end)
|
||||
|
||||
sensor = Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts)
|
||||
|
||||
# OID "1.3.6.1.4.1.17713.21.1.2.1.0" -> last 2 parts "1_0"
|
||||
# Description "RSSI" -> slug "rssi"
|
||||
assert sensor.sensor_index == "wireless_rssi_1_0"
|
||||
end
|
||||
|
||||
test "slugifies multi-word descriptions" do
|
||||
sensor_def = %{
|
||||
oid: "1.3.6.1.4.1.100.5.3.0",
|
||||
sensor_type: "tx_power",
|
||||
sensor_descr: "Tx Power Level",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:ok, 20} end)
|
||||
|
||||
sensor = Vendor.fetch_sensor_value(sensor_def, @client_opts)
|
||||
|
||||
assert sensor.sensor_index == "wireless_tx_power_level_3_0"
|
||||
end
|
||||
|
||||
test "returns nil when SNMP returns an error" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:error, :timeout} end)
|
||||
|
||||
assert Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts) == nil
|
||||
end
|
||||
|
||||
test "returns nil when SNMP returns no_such_object" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts) == nil
|
||||
end
|
||||
|
||||
test "returns nil when SNMP returns a string value instead of integer" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:ok, "not_an_integer"} end)
|
||||
|
||||
assert Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts) == nil
|
||||
end
|
||||
|
||||
test "returns nil when SNMP returns nil value" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:ok, nil} end)
|
||||
|
||||
assert Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts) == nil
|
||||
end
|
||||
|
||||
test "handles zero integer value" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:ok, 0} end)
|
||||
|
||||
sensor = Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts)
|
||||
|
||||
assert sensor.last_value == 0
|
||||
end
|
||||
|
||||
test "handles negative integer value" do
|
||||
expect(SnmpMock, :get, fn _, _, _ -> {:ok, -90} end)
|
||||
|
||||
sensor = Vendor.fetch_sensor_value(@sample_sensor_def, @client_opts)
|
||||
|
||||
assert sensor.last_value == -90
|
||||
end
|
||||
end
|
||||
|
||||
describe "walk_table_sensors/3" do
|
||||
test "returns sensors from walk results with integer values" do
|
||||
base_oid = "1.3.6.1.4.1.100.1"
|
||||
|
||||
sensor_def = %{
|
||||
sensor_type: "client_rssi",
|
||||
sensor_descr: "Client RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.100.1", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.100.1.1", value: -55},
|
||||
%{oid: "1.3.6.1.4.1.100.1.2", value: -70}
|
||||
]}
|
||||
end)
|
||||
|
||||
sensors = Vendor.walk_table_sensors(base_oid, sensor_def, @client_opts)
|
||||
|
||||
assert length(sensors) == 2
|
||||
|
||||
[first, second] = sensors
|
||||
|
||||
assert first.sensor_type == "client_rssi"
|
||||
assert first.sensor_index == "client_rssi_1"
|
||||
assert first.sensor_oid == "1.3.6.1.4.1.100.1.1"
|
||||
assert first.sensor_descr == "Client RSSI 1"
|
||||
assert first.sensor_unit == "dBm"
|
||||
assert first.sensor_divisor == 1
|
||||
assert first.last_value == -55
|
||||
|
||||
assert second.sensor_index == "client_rssi_2"
|
||||
assert second.sensor_oid == "1.3.6.1.4.1.100.1.2"
|
||||
assert second.sensor_descr == "Client RSSI 2"
|
||||
assert second.last_value == -70
|
||||
end
|
||||
|
||||
test "returns empty list when walk returns error" do
|
||||
sensor_def = %{
|
||||
sensor_type: "client_rssi",
|
||||
sensor_descr: "Client RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
|
||||
|
||||
assert Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts) == []
|
||||
end
|
||||
|
||||
test "returns empty list when walk returns empty map" do
|
||||
sensor_def = %{
|
||||
sensor_type: "client_rssi",
|
||||
sensor_descr: "Client RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, [%{oid: "x", value: "y"}]} end)
|
||||
|
||||
# Client.walk converts list to map, so the Vendor module receives a map
|
||||
# But we need to mock at the SnmpMock level. Client.walk returns {:ok, map}
|
||||
# The walk results go through Client.walk which converts to a map.
|
||||
# Let's mock so it returns an empty map after conversion.
|
||||
assert Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts) == []
|
||||
end
|
||||
|
||||
test "filters out non-integer values from walk results" do
|
||||
sensor_def = %{
|
||||
sensor_type: "client_rssi",
|
||||
sensor_descr: "Client RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.100.1.1", value: -55},
|
||||
%{oid: "1.3.6.1.4.1.100.1.2", value: "not_integer"},
|
||||
%{oid: "1.3.6.1.4.1.100.1.3", value: -70}
|
||||
]}
|
||||
end)
|
||||
|
||||
sensors = Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts)
|
||||
|
||||
# Non-integer entries are filtered out by build_table_sensor returning nil
|
||||
assert length(sensors) == 2
|
||||
assert Enum.at(sensors, 0).last_value == -55
|
||||
assert Enum.at(sensors, 1).last_value == -70
|
||||
end
|
||||
|
||||
test "indexes start at 1 and increment sequentially" do
|
||||
sensor_def = %{
|
||||
sensor_type: "tx_power",
|
||||
sensor_descr: "Tx Power",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.4.1.100.1.1", value: 20},
|
||||
%{oid: "1.3.6.1.4.1.100.1.2", value: 21},
|
||||
%{oid: "1.3.6.1.4.1.100.1.3", value: 22}
|
||||
]}
|
||||
end)
|
||||
|
||||
sensors = Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts)
|
||||
|
||||
assert Enum.at(sensors, 0).sensor_index == "tx_power_1"
|
||||
assert Enum.at(sensors, 0).sensor_descr == "Tx Power 1"
|
||||
assert Enum.at(sensors, 1).sensor_index == "tx_power_2"
|
||||
assert Enum.at(sensors, 1).sensor_descr == "Tx Power 2"
|
||||
assert Enum.at(sensors, 2).sensor_index == "tx_power_3"
|
||||
assert Enum.at(sensors, 2).sensor_descr == "Tx Power 3"
|
||||
end
|
||||
|
||||
test "returns empty list when walk returns no_such_object" do
|
||||
sensor_def = %{
|
||||
sensor_type: "client_rssi",
|
||||
sensor_descr: "Client RSSI",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts) == []
|
||||
end
|
||||
|
||||
test "handles single entry walk result" do
|
||||
sensor_def = %{
|
||||
sensor_type: "channel",
|
||||
sensor_descr: "Channel",
|
||||
sensor_unit: "MHz",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok, [%{oid: "1.3.6.1.4.1.100.1.1", value: 5180}]}
|
||||
end)
|
||||
|
||||
sensors = Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts)
|
||||
|
||||
assert length(sensors) == 1
|
||||
assert hd(sensors).sensor_index == "channel_1"
|
||||
assert hd(sensors).last_value == 5180
|
||||
end
|
||||
|
||||
test "handles zero value in walk results" do
|
||||
sensor_def = %{
|
||||
sensor_type: "noise",
|
||||
sensor_descr: "Noise Floor",
|
||||
sensor_unit: "dBm",
|
||||
sensor_divisor: 1
|
||||
}
|
||||
|
||||
expect(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok, [%{oid: "1.3.6.1.4.1.100.1.1", value: 0}]}
|
||||
end)
|
||||
|
||||
sensors = Vendor.walk_table_sensors("1.3.6.1.4.1.100.1", sensor_def, @client_opts)
|
||||
|
||||
assert length(sensors) == 1
|
||||
assert hd(sensors).last_value == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
defmodule ToweropsWeb.UserResetPasswordControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
setup do
|
||||
%{user: user_fixture(enable_totp: false)}
|
||||
end
|
||||
|
||||
describe "GET /users/reset-password" do
|
||||
test "renders the reset password page", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/reset-password")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Forgot your password?"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/reset-password" do
|
||||
test "sends password reset email for existing user", %{conn: conn, user: user} do
|
||||
conn =
|
||||
post(conn, ~p"/users/reset-password", %{"user" => %{"email" => user.email}})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
end
|
||||
|
||||
test "does not reveal if email exists (non-existent email)", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/users/reset-password", %{
|
||||
"user" => %{"email" => "nobody@example.com"}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,183 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
|
||||
alias ToweropsWeb.TimeHelpers
|
||||
|
||||
describe "to_user_timezone/2" do
|
||||
test "returns shifted datetime and abbreviation for known timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert {:ok, shifted, "EST"} = TimeHelpers.to_user_timezone(datetime, "America/New_York")
|
||||
# UTC-5 => 09:34
|
||||
assert shifted.hour == 9
|
||||
assert shifted.minute == 34
|
||||
end
|
||||
|
||||
test "returns unchanged datetime for UTC" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert {:ok, shifted, "UTC"} = TimeHelpers.to_user_timezone(datetime, "UTC")
|
||||
assert shifted.hour == 14
|
||||
assert shifted.minute == 34
|
||||
end
|
||||
|
||||
test "handles positive offset timezone (Asia/Tokyo, UTC+9)" do
|
||||
datetime = ~U[2026-01-15 14:00:00Z]
|
||||
assert {:ok, shifted, "JST"} = TimeHelpers.to_user_timezone(datetime, "Asia/Tokyo")
|
||||
assert shifted.hour == 23
|
||||
end
|
||||
|
||||
test "handles date rollover forward (Asia/Tokyo near midnight)" do
|
||||
datetime = ~U[2026-01-15 20:00:00Z]
|
||||
assert {:ok, shifted, "JST"} = TimeHelpers.to_user_timezone(datetime, "Asia/Tokyo")
|
||||
# 20:00 + 9h = 05:00 next day
|
||||
assert shifted.hour == 5
|
||||
assert shifted.day == 16
|
||||
end
|
||||
|
||||
test "handles date rollover backward (America/Los_Angeles early UTC)" do
|
||||
datetime = ~U[2026-01-15 02:00:00Z]
|
||||
assert {:ok, shifted, "PST"} = TimeHelpers.to_user_timezone(datetime, "America/Los_Angeles")
|
||||
# 02:00 - 8h = 18:00 previous day
|
||||
assert shifted.hour == 18
|
||||
assert shifted.day == 14
|
||||
end
|
||||
|
||||
test "returns error for unknown timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert {:error, :unknown_timezone} = TimeHelpers.to_user_timezone(datetime, "Invalid/Zone")
|
||||
end
|
||||
|
||||
test "handles all supported timezones without error" do
|
||||
datetime = ~U[2026-01-15 12:00:00Z]
|
||||
|
||||
timezones = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"America/Phoenix",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Dubai",
|
||||
"Australia/Sydney"
|
||||
]
|
||||
|
||||
for tz <- timezones do
|
||||
assert {:ok, _shifted, _abbr} = TimeHelpers.to_user_timezone(datetime, tz)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_time_ago/1" do
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_time_ago(nil) == "Never"
|
||||
end
|
||||
|
||||
test "formats seconds ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -30, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result =~ "s ago"
|
||||
end
|
||||
|
||||
test "formats 0 seconds ago for very recent datetime" do
|
||||
datetime = DateTime.utc_now()
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result =~ "s ago"
|
||||
end
|
||||
|
||||
test "formats minutes ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -120, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "2m ago"
|
||||
end
|
||||
|
||||
test "formats 1 minute ago at 60 seconds" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -65, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "1m ago"
|
||||
end
|
||||
|
||||
test "formats hours ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -7200, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "2h ago"
|
||||
end
|
||||
|
||||
test "formats 1 hour ago at 3600 seconds" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "1h ago"
|
||||
end
|
||||
|
||||
test "formats days ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -172_800, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "2d ago"
|
||||
end
|
||||
|
||||
test "formats 1 day ago at 86400 seconds" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -86_400, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "1d ago"
|
||||
end
|
||||
|
||||
test "formats months ago" do
|
||||
# 60 days = 5,184,000 seconds
|
||||
datetime = DateTime.add(DateTime.utc_now(), -5_184_000, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "2mo ago"
|
||||
end
|
||||
|
||||
test "formats 1 month ago at 30 days" do
|
||||
# 30 days = 2,592,000 seconds
|
||||
datetime = DateTime.add(DateTime.utc_now(), -2_592_000, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "1mo ago"
|
||||
end
|
||||
|
||||
test "formats years ago" do
|
||||
# 2 years = 63,072,000 seconds
|
||||
datetime = DateTime.add(DateTime.utc_now(), -63_072_000, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "2y ago"
|
||||
end
|
||||
|
||||
test "formats 1 year ago at 365 days" do
|
||||
# 365 days = 31,536,000 seconds
|
||||
datetime = DateTime.add(DateTime.utc_now(), -31_536_000, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
assert result == "1y ago"
|
||||
end
|
||||
|
||||
test "handles future datetime by treating it as just now (clock skew)" do
|
||||
# Simulate clock skew where datetime is in the future
|
||||
datetime = DateTime.add(DateTime.utc_now(), 5, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
# Should not crash and should show "0s ago" or similar
|
||||
assert result =~ "s ago"
|
||||
end
|
||||
|
||||
test "handles future datetime by many seconds (edge case)" do
|
||||
# Extreme clock skew - datetime 30 seconds in the future
|
||||
datetime = DateTime.add(DateTime.utc_now(), 30, :second)
|
||||
result = TimeHelpers.format_time_ago(datetime)
|
||||
# Should clamp to 0 and show as recent
|
||||
assert result =~ "s ago"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_datetime/1" do
|
||||
test "formats datetime in UTC with 12h format by default" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime) == "Jan 15, 2026 at 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil" do
|
||||
assert TimeHelpers.format_datetime(nil) == "Never"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_datetime/2" do
|
||||
test "formats datetime in UTC when timezone is nil" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
|
|
@ -16,16 +193,14 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
|
||||
test "converts datetime to America/New_York timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 09:34 EST (UTC-5)
|
||||
assert TimeHelpers.format_datetime(datetime, "America/New_York") =~ "Jan 15, 2026 at 09:34 AM"
|
||||
assert TimeHelpers.format_datetime(datetime, "America/New_York") =~ "EST"
|
||||
result = TimeHelpers.format_datetime(datetime, "America/New_York")
|
||||
assert result == "Jan 15, 2026 at 09:34 AM EST"
|
||||
end
|
||||
|
||||
test "converts datetime to America/Los_Angeles timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 06:34 PST (UTC-8)
|
||||
assert TimeHelpers.format_datetime(datetime, "America/Los_Angeles") =~ "Jan 15, 2026 at 06:34 AM"
|
||||
assert TimeHelpers.format_datetime(datetime, "America/Los_Angeles") =~ "PST"
|
||||
result = TimeHelpers.format_datetime(datetime, "America/Los_Angeles")
|
||||
assert result == "Jan 15, 2026 at 06:34 AM PST"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
|
|
@ -39,6 +214,66 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "format_datetime/3" do
|
||||
test "formats datetime in 12h format" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "UTC", "12h") == "Jan 15, 2026 at 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "formats datetime in 24h format" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "UTC", "24h") == "Jan 15, 2026 at 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "formats datetime in 24h format with timezone conversion" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
result = TimeHelpers.format_datetime(datetime, "America/New_York", "24h")
|
||||
assert result == "Jan 15, 2026 at 09:34:00 EST"
|
||||
end
|
||||
|
||||
test "formats datetime in 12h format with timezone conversion" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
result = TimeHelpers.format_datetime(datetime, "America/New_York", "12h")
|
||||
assert result == "Jan 15, 2026 at 09:34 AM EST"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_datetime(nil, "UTC", "24h") == "Never"
|
||||
assert TimeHelpers.format_datetime(nil, "UTC", "12h") == "Never"
|
||||
end
|
||||
|
||||
test "handles nil timezone by defaulting to UTC" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, nil, "24h") == "Jan 15, 2026 at 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone in 24h format" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "Fake/Zone", "24h") == "Jan 15, 2026 at 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "formats midnight correctly in 12h format" do
|
||||
datetime = ~U[2026-01-15 00:00:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "UTC", "12h") == "Jan 15, 2026 at 12:00 AM UTC"
|
||||
end
|
||||
|
||||
test "formats noon correctly in 12h format" do
|
||||
datetime = ~U[2026-01-15 12:00:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "UTC", "12h") == "Jan 15, 2026 at 12:00 PM UTC"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_date/1" do
|
||||
test "formats date in UTC by default" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_date(datetime) == "Jan 15, 2026"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil" do
|
||||
assert TimeHelpers.format_date(nil) == "Never"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_date/2" do
|
||||
test "formats date in UTC when timezone is nil" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
|
|
@ -51,17 +286,21 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
end
|
||||
|
||||
test "converts to different date in America/Los_Angeles for early UTC times" do
|
||||
# 02:00 UTC on Jan 15 = 18:00 PST on Jan 14 (UTC-8)
|
||||
datetime = ~U[2026-01-15 02:00:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "America/Los_Angeles") == "Jan 14, 2026"
|
||||
end
|
||||
|
||||
test "keeps same date for late UTC times in America/Los_Angeles" do
|
||||
# 20:00 UTC on Jan 15 = 12:00 PST on Jan 15 (UTC-8)
|
||||
datetime = ~U[2026-01-15 20:00:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "America/Los_Angeles") == "Jan 15, 2026"
|
||||
end
|
||||
|
||||
test "rolls date forward for positive offset timezones near midnight" do
|
||||
# 23:00 UTC on Jan 15 + 9h (Tokyo) = 08:00 Jan 16
|
||||
datetime = ~U[2026-01-15 23:00:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "Asia/Tokyo") == "Jan 16, 2026"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "Invalid/Timezone") == "Jan 15, 2026"
|
||||
|
|
@ -73,6 +312,17 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "format_iso8601/1" do
|
||||
test "formats datetime in UTC with 24h format by default" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime) == "2026-01-15 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil" do
|
||||
assert TimeHelpers.format_iso8601(nil) == "Never"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_iso8601/2" do
|
||||
test "formats datetime in UTC when timezone is nil" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
|
|
@ -86,18 +336,14 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
|
||||
test "converts datetime to America/New_York timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 09:34 EST (UTC-5)
|
||||
result = TimeHelpers.format_iso8601(datetime, "America/New_York")
|
||||
assert result =~ "2026-01-15 09:34:00"
|
||||
assert result =~ "EST"
|
||||
assert result == "2026-01-15 09:34:00 EST"
|
||||
end
|
||||
|
||||
test "converts datetime to America/Los_Angeles timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 06:34 PST (UTC-8)
|
||||
result = TimeHelpers.format_iso8601(datetime, "America/Los_Angeles")
|
||||
assert result =~ "2026-01-15 06:34:00"
|
||||
assert result =~ "PST"
|
||||
assert result == "2026-01-15 06:34:00 PST"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
|
|
@ -111,29 +357,111 @@ defmodule ToweropsWeb.TimeHelpersTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "format_time_ago/1" do
|
||||
test "formats seconds ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -30, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "s ago"
|
||||
describe "format_iso8601/3" do
|
||||
test "formats datetime in 24h format" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, "UTC", "24h") == "2026-01-15 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "formats minutes ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -120, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "m ago"
|
||||
test "formats datetime in 12h format" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, "UTC", "12h") == "2026-01-15 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "formats hours ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -7200, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "h ago"
|
||||
test "formats datetime in 12h format with timezone conversion" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
result = TimeHelpers.format_iso8601(datetime, "America/New_York", "12h")
|
||||
assert result == "2026-01-15 09:34 AM EST"
|
||||
end
|
||||
|
||||
test "formats days ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -172_800, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "d ago"
|
||||
test "formats datetime in 24h format with timezone conversion" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
result = TimeHelpers.format_iso8601(datetime, "America/New_York", "24h")
|
||||
assert result == "2026-01-15 09:34:00 EST"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_time_ago(nil) == "Never"
|
||||
assert TimeHelpers.format_iso8601(nil, "UTC", "24h") == "Never"
|
||||
assert TimeHelpers.format_iso8601(nil, "UTC", "12h") == "Never"
|
||||
end
|
||||
|
||||
test "handles nil timezone by defaulting to UTC" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, nil, "12h") == "2026-01-15 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone in 12h format" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, "Fake/Zone", "12h") == "2026-01-15 02:34 PM UTC"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_utc_hour/1" do
|
||||
test "formats hour in UTC with AM" do
|
||||
result = TimeHelpers.format_utc_hour(7)
|
||||
assert result == "7:00 AM UTC"
|
||||
end
|
||||
|
||||
test "formats midnight as 12 AM" do
|
||||
result = TimeHelpers.format_utc_hour(0)
|
||||
assert result == "12:00 AM UTC"
|
||||
end
|
||||
|
||||
test "formats noon as 12 PM" do
|
||||
result = TimeHelpers.format_utc_hour(12)
|
||||
assert result == "12:00 PM UTC"
|
||||
end
|
||||
|
||||
test "formats afternoon hours with PM" do
|
||||
result = TimeHelpers.format_utc_hour(15)
|
||||
assert result == "3:00 PM UTC"
|
||||
end
|
||||
|
||||
test "formats hour 23 correctly" do
|
||||
result = TimeHelpers.format_utc_hour(23)
|
||||
assert result == "11:00 PM UTC"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_utc_hour/2" do
|
||||
test "converts UTC hour to America/New_York" do
|
||||
# 7 UTC - 5h = 2 AM EST
|
||||
result = TimeHelpers.format_utc_hour(7, "America/New_York")
|
||||
assert result == "2:00 AM EST"
|
||||
end
|
||||
|
||||
test "converts UTC hour to Asia/Tokyo" do
|
||||
# 7 UTC + 9h = 16:00 JST = 4 PM
|
||||
result = TimeHelpers.format_utc_hour(7, "Asia/Tokyo")
|
||||
assert result == "4:00 PM JST"
|
||||
end
|
||||
|
||||
test "converts midnight UTC to America/Los_Angeles" do
|
||||
# 0 UTC - 8h = 16:00 previous day PST = 4 PM
|
||||
result = TimeHelpers.format_utc_hour(0, "America/Los_Angeles")
|
||||
assert result == "4:00 PM PST"
|
||||
end
|
||||
|
||||
test "converts noon UTC to Europe/Paris" do
|
||||
# 12 UTC + 1h = 13:00 CET = 1 PM
|
||||
result = TimeHelpers.format_utc_hour(12, "Europe/Paris")
|
||||
assert result == "1:00 PM CET"
|
||||
end
|
||||
|
||||
test "converts UTC hour to Australia/Sydney" do
|
||||
# 14 UTC + 10h = 24 => 0 next day = 12 AM
|
||||
result = TimeHelpers.format_utc_hour(14, "Australia/Sydney")
|
||||
assert result == "12:00 AM AEST"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
result = TimeHelpers.format_utc_hour(7, "Invalid/Zone")
|
||||
assert result == "7:00 AM UTC"
|
||||
end
|
||||
|
||||
test "handles UTC explicitly" do
|
||||
result = TimeHelpers.format_utc_hour(15, "UTC")
|
||||
assert result == "3:00 PM UTC"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
113
test/towerops_web/plugs/mobile_auth_test.exs
Normal file
113
test/towerops_web/plugs/mobile_auth_test.exs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
defmodule ToweropsWeb.Plugs.MobileAuthTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.MobileSessions
|
||||
alias ToweropsWeb.Plugs.MobileAuth
|
||||
|
||||
describe "init/1" do
|
||||
test "returns options unchanged" do
|
||||
opts = [some: :option]
|
||||
assert MobileAuth.init(opts) == opts
|
||||
end
|
||||
|
||||
test "returns empty list unchanged" do
|
||||
assert MobileAuth.init([]) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "call/2 with valid authorization" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
{:ok, session} =
|
||||
MobileSessions.create_mobile_session(%{
|
||||
user_id: user.id,
|
||||
device_name: "Test iPhone"
|
||||
})
|
||||
|
||||
%{user: user, session: session}
|
||||
end
|
||||
|
||||
test "authenticates successfully with valid Bearer token", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
session: session
|
||||
} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("authorization", "Bearer #{session.token}")
|
||||
|> MobileAuth.call([])
|
||||
|
||||
refute conn.halted
|
||||
assert conn.assigns.current_user.id == user.id
|
||||
assert conn.assigns.current_mobile_session.id == session.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "call/2 with missing authorization" do
|
||||
test "returns 401 when Authorization header is missing", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> MobileAuth.call([])
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 401
|
||||
|
||||
assert json_response(conn, 401) == %{
|
||||
"error" => "Authorization header is missing or invalid"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "call/2 with invalid token" do
|
||||
test "returns 401 for non-existent token", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("authorization", "Bearer invalid-token-xxx")
|
||||
|> MobileAuth.call([])
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 401
|
||||
|
||||
assert json_response(conn, 401) == %{
|
||||
"error" => "Invalid or expired authentication token"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "call/2 with expired session" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
expires_at = DateTime.add(DateTime.utc_now(), -1, :day)
|
||||
|
||||
{:ok, session} =
|
||||
MobileSessions.create_mobile_session(%{
|
||||
user_id: user.id,
|
||||
device_name: "Expired iPhone",
|
||||
expires_at: expires_at
|
||||
})
|
||||
|
||||
%{user: user, session: session}
|
||||
end
|
||||
|
||||
test "returns 401 for expired session token", %{conn: conn, session: session} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("authorization", "Bearer #{session.token}")
|
||||
|> MobileAuth.call([])
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 401
|
||||
|
||||
assert json_response(conn, 401) == %{
|
||||
"error" => "Invalid or expired authentication token"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue