more tests and fixes

This commit is contained in:
Graham McIntire 2026-02-07 11:50:18 -06:00
parent 141c775230
commit 3931a9c14a
No known key found for this signature in database
34 changed files with 8240 additions and 376 deletions

View file

@ -1,6 +1,9 @@
{
"permissions": {
"allow": [
"Bash(mix test:*)",
"Bash(mix format:*)",
"Bash(mix credo:*)"
]
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

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

View 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

View file

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

View 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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

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

View 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

View file

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

View file

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

View 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

View 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

View file

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

View file

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

View 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