test: lift coverage 81.88% → 82.45% with more agent-driven tests
- Snmp.Discovery.create_checks_from_discovery: zero-data path covered; full-data path tagged :skip (existing lib/ has KeyError on processor_descr/ sensor_class — real bug, lib unchanged per scope) - Proto.Decode (new file): round-trip + validation for decode_agent_heartbeat, decode_metric_batch, decode_snmp_result, decode_agent_error, decode_credential_test_result, decode_mikrotik_result, decode_monitoring_check, decode_lldp_topology_result, decode_check_result - DeviceLive.Show.events (new file): 39 tests covering checks/backups/preseem/ gaiia tabs, set_capacity, agent handle_info handlers, FormComponent callbacks. Coverage 64% → 77.6%. - GraphLive.Show.events (new file): check-based graph paths, range switching across all valid ranges, less-common sensor types, access control redirects. terminate-cleanup test tagged :skip. - Org.SettingsLive.more_events (new file): more handle_event branches for integrations, set_netbox_sync_direction, default_org handling.
This commit is contained in:
parent
dcd75e55be
commit
53f24bb95e
5 changed files with 2036 additions and 0 deletions
453
test/towerops/proto/decode_test.exs
Normal file
453
test/towerops/proto/decode_test.exs
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
defmodule Towerops.Proto.DecodeTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Towerops.Proto.Decode
|
||||||
|
alias Towerops.Proto.Encode
|
||||||
|
alias Towerops.Proto.Types
|
||||||
|
|
||||||
|
@valid_uuid "11111111-2222-3333-4444-555555555555"
|
||||||
|
@other_uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
@third_uuid "12345678-1234-1234-1234-1234567890ab"
|
||||||
|
@valid_timestamp 1_700_000_000
|
||||||
|
|
||||||
|
describe "decode_agent_heartbeat/1" do
|
||||||
|
test "decodes a valid heartbeat round-trip" do
|
||||||
|
hb = %Types.AgentHeartbeat{
|
||||||
|
version: "1.2.3",
|
||||||
|
hostname: "agent-host",
|
||||||
|
uptime_seconds: 3600,
|
||||||
|
ip_address: "10.0.0.1",
|
||||||
|
arch: "x86_64"
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_agent_heartbeat(hb)
|
||||||
|
assert {:ok, result} = Decode.decode_agent_heartbeat(binary)
|
||||||
|
assert result.version == "1.2.3"
|
||||||
|
assert result.hostname == "agent-host"
|
||||||
|
assert result.uptime_seconds == 3600
|
||||||
|
assert result.ip_address == "10.0.0.1"
|
||||||
|
assert result.arch == "x86_64"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "decodes empty binary as default heartbeat" do
|
||||||
|
assert {:ok, result} = Decode.decode_agent_heartbeat(<<>>)
|
||||||
|
assert result.version == ""
|
||||||
|
assert result.uptime_seconds == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error on malformed binary" do
|
||||||
|
# Truncated varint (continuation bit set, no following byte)
|
||||||
|
assert {:error, _reason} = Decode.decode_agent_heartbeat(<<0xFF>>)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects heartbeat with overlong version string" do
|
||||||
|
hb = %Types.AgentHeartbeat{
|
||||||
|
version: String.duplicate("a", 256),
|
||||||
|
hostname: "h",
|
||||||
|
uptime_seconds: 1,
|
||||||
|
ip_address: "",
|
||||||
|
arch: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_agent_heartbeat(hb)
|
||||||
|
assert {:error, {:string_too_long, _}} = Decode.decode_agent_heartbeat(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_heartbeat_metadata/1" do
|
||||||
|
test "decodes valid heartbeat metadata round-trip" do
|
||||||
|
m = %Types.HeartbeatMetadata{
|
||||||
|
version: "2.0.0",
|
||||||
|
hostname: "meta-host",
|
||||||
|
uptime_seconds: 99
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_heartbeat_metadata(m)
|
||||||
|
assert {:ok, result} = Decode.decode_heartbeat_metadata(binary)
|
||||||
|
assert result.version == "2.0.0"
|
||||||
|
assert result.hostname == "meta-host"
|
||||||
|
assert result.uptime_seconds == 99
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error on malformed binary" do
|
||||||
|
assert {:error, _} = Decode.decode_heartbeat_metadata(<<0xFF>>)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_heartbeat_response/1" do
|
||||||
|
test "decodes valid heartbeat response round-trip" do
|
||||||
|
r = %Types.HeartbeatResponse{status: "ok"}
|
||||||
|
binary = Encode.encode_heartbeat_response(r)
|
||||||
|
assert {:ok, result} = Decode.decode_heartbeat_response(binary)
|
||||||
|
assert result.status == "ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "decodes empty heartbeat response" do
|
||||||
|
assert {:ok, result} = Decode.decode_heartbeat_response(<<>>)
|
||||||
|
assert result.status == ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_metric_batch/1" do
|
||||||
|
test "decodes batch with sensor_reading metric" do
|
||||||
|
sr = %Types.SensorReading{
|
||||||
|
sensor_id: "sensor-1",
|
||||||
|
value: 42.5,
|
||||||
|
status: "ok",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = %Types.MetricBatch{metrics: [{:sensor_reading, sr}]}
|
||||||
|
binary = Encode.encode_metric_batch(batch)
|
||||||
|
|
||||||
|
assert {:ok, result} = Decode.decode_metric_batch(binary)
|
||||||
|
assert [{:sensor_reading, decoded}] = result.metrics
|
||||||
|
assert decoded.sensor_id == "sensor-1"
|
||||||
|
assert decoded.value == 42.5
|
||||||
|
assert decoded.status == "ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "decodes batch with monitoring_check metric" do
|
||||||
|
mc = %Types.MonitoringCheck{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
status: "up",
|
||||||
|
response_time_ms: 12.0,
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = %Types.MetricBatch{metrics: [{:monitoring_check, mc}]}
|
||||||
|
binary = Encode.encode_metric_batch(batch)
|
||||||
|
|
||||||
|
assert {:ok, result} = Decode.decode_metric_batch(binary)
|
||||||
|
assert [{:monitoring_check, decoded}] = result.metrics
|
||||||
|
assert decoded.device_id == @valid_uuid
|
||||||
|
assert decoded.status == "up"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "decodes empty batch" do
|
||||||
|
assert {:ok, %Types.MetricBatch{metrics: []}} = Decode.decode_metric_batch(<<>>)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error on malformed binary" do
|
||||||
|
assert {:error, _} = Decode.decode_metric_batch(<<0xFF>>)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_snmp_result/1" do
|
||||||
|
test "decodes valid snmp_result round-trip" do
|
||||||
|
sr = %Types.SnmpResult{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_type: :poll,
|
||||||
|
oid_values: %{"1.3.6.1.2.1.1.1.0" => "Linux"},
|
||||||
|
timestamp: @valid_timestamp,
|
||||||
|
job_id: "job-abc"
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_snmp_result(sr)
|
||||||
|
assert {:ok, result} = Decode.decode_snmp_result(binary)
|
||||||
|
assert result.device_id == @valid_uuid
|
||||||
|
assert result.job_type == :poll
|
||||||
|
assert result.oid_values == %{"1.3.6.1.2.1.1.1.0" => "Linux"}
|
||||||
|
assert result.timestamp == @valid_timestamp
|
||||||
|
assert result.job_id == "job-abc"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects snmp_result with empty device_id" do
|
||||||
|
sr = %Types.SnmpResult{
|
||||||
|
device_id: "",
|
||||||
|
job_type: :poll,
|
||||||
|
oid_values: %{},
|
||||||
|
timestamp: @valid_timestamp,
|
||||||
|
job_id: "job-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_snmp_result(sr)
|
||||||
|
assert {:error, {:invalid_device_id, _}} = Decode.decode_snmp_result(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects snmp_result with bad UUID" do
|
||||||
|
sr = %Types.SnmpResult{
|
||||||
|
device_id: "not-a-uuid",
|
||||||
|
job_type: :poll,
|
||||||
|
oid_values: %{},
|
||||||
|
timestamp: @valid_timestamp,
|
||||||
|
job_id: "job-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_snmp_result(sr)
|
||||||
|
assert {:error, {:invalid_device_id, _}} = Decode.decode_snmp_result(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects snmp_result with negative timestamp" do
|
||||||
|
# Encode by hand with a negative int64 timestamp via the encoder
|
||||||
|
sr = %Types.SnmpResult{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_type: :poll,
|
||||||
|
oid_values: %{},
|
||||||
|
timestamp: -1,
|
||||||
|
job_id: "job-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_snmp_result(sr)
|
||||||
|
assert {:error, {:invalid_timestamp, _}} = Decode.decode_snmp_result(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_agent_error/1" do
|
||||||
|
test "decodes valid agent_error round-trip" do
|
||||||
|
e = %Types.AgentError{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_id: @other_uuid,
|
||||||
|
message: "SNMP timeout",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_agent_error(e)
|
||||||
|
assert {:ok, result} = Decode.decode_agent_error(binary)
|
||||||
|
assert result.device_id == @valid_uuid
|
||||||
|
assert result.job_id == @other_uuid
|
||||||
|
assert result.message == "SNMP timeout"
|
||||||
|
assert result.timestamp == @valid_timestamp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects agent_error with invalid device_id" do
|
||||||
|
e = %Types.AgentError{
|
||||||
|
device_id: "bad-id",
|
||||||
|
job_id: @other_uuid,
|
||||||
|
message: "x",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_agent_error(e)
|
||||||
|
assert {:error, {:invalid_device_id, _}} = Decode.decode_agent_error(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects agent_error with invalid job_id" do
|
||||||
|
e = %Types.AgentError{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_id: "not-uuid",
|
||||||
|
message: "x",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_agent_error(e)
|
||||||
|
assert {:error, {:invalid_job_id, _}} = Decode.decode_agent_error(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_credential_test_result/1" do
|
||||||
|
test "decodes valid credential_test_result round-trip" do
|
||||||
|
r = %Types.CredentialTestResult{
|
||||||
|
test_id: @valid_uuid,
|
||||||
|
success: true,
|
||||||
|
error_message: "",
|
||||||
|
system_description: "Cisco IOS",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_credential_test_result(r)
|
||||||
|
assert {:ok, result} = Decode.decode_credential_test_result(binary)
|
||||||
|
assert result.test_id == @valid_uuid
|
||||||
|
assert result.success == true
|
||||||
|
assert result.system_description == "Cisco IOS"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects credential_test_result with bad test_id" do
|
||||||
|
r = %Types.CredentialTestResult{
|
||||||
|
test_id: "bad",
|
||||||
|
success: false,
|
||||||
|
error_message: "fail",
|
||||||
|
system_description: "",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_credential_test_result(r)
|
||||||
|
assert {:error, {:invalid_test_id, _}} = Decode.decode_credential_test_result(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects credential_test_result with overlong error_message" do
|
||||||
|
r = %Types.CredentialTestResult{
|
||||||
|
test_id: @valid_uuid,
|
||||||
|
success: false,
|
||||||
|
error_message: String.duplicate("e", 1_001),
|
||||||
|
system_description: "",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_credential_test_result(r)
|
||||||
|
assert {:error, {:string_too_long, _}} = Decode.decode_credential_test_result(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_mikrotik_result/1" do
|
||||||
|
test "decodes valid mikrotik_result round-trip with sentences" do
|
||||||
|
sentence = %Types.MikrotikSentence{
|
||||||
|
attributes: %{"name" => "ether1", "running" => "true"}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = %Types.MikrotikResult{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_id: @other_uuid,
|
||||||
|
sentences: [sentence],
|
||||||
|
error: "",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_mikrotik_result(r)
|
||||||
|
assert {:ok, result} = Decode.decode_mikrotik_result(binary)
|
||||||
|
assert result.device_id == @valid_uuid
|
||||||
|
assert result.job_id == @other_uuid
|
||||||
|
assert [%Types.MikrotikSentence{attributes: attrs}] = result.sentences
|
||||||
|
assert attrs["name"] == "ether1"
|
||||||
|
assert attrs["running"] == "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects mikrotik_result with bad device_id" do
|
||||||
|
r = %Types.MikrotikResult{
|
||||||
|
device_id: "bad",
|
||||||
|
job_id: @other_uuid,
|
||||||
|
sentences: [],
|
||||||
|
error: "",
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_mikrotik_result(r)
|
||||||
|
assert {:error, {:invalid_device_id, _}} = Decode.decode_mikrotik_result(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error on malformed binary" do
|
||||||
|
assert {:error, _} = Decode.decode_mikrotik_result(<<0xFF>>)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_monitoring_check/1" do
|
||||||
|
test "decodes valid monitoring_check round-trip" do
|
||||||
|
mc = %Types.MonitoringCheck{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
status: "up",
|
||||||
|
response_time_ms: 5.5,
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_monitoring_check(mc)
|
||||||
|
assert {:ok, result} = Decode.decode_monitoring_check(binary)
|
||||||
|
assert result.device_id == @valid_uuid
|
||||||
|
assert result.status == "up"
|
||||||
|
assert result.response_time_ms == 5.5
|
||||||
|
assert result.timestamp == @valid_timestamp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects monitoring_check with empty device_id" do
|
||||||
|
mc = %Types.MonitoringCheck{
|
||||||
|
device_id: "",
|
||||||
|
status: "up",
|
||||||
|
response_time_ms: 1.0,
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_monitoring_check(mc)
|
||||||
|
assert {:error, {:invalid_device_id, _}} = Decode.decode_monitoring_check(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects monitoring_check with response_time_ms above max" do
|
||||||
|
mc = %Types.MonitoringCheck{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
status: "up",
|
||||||
|
response_time_ms: 60_001.0,
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_monitoring_check(mc)
|
||||||
|
assert {:error, {:invalid_response_time, _}} = Decode.decode_monitoring_check(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_lldp_topology_result/1" do
|
||||||
|
test "decodes valid lldp_topology_result round-trip with neighbors" do
|
||||||
|
neighbor = %Types.LldpNeighbor{
|
||||||
|
neighbor_name: "switch-1",
|
||||||
|
local_port: "Gi0/1",
|
||||||
|
remote_port: "Gi0/24",
|
||||||
|
remote_port_id: "24",
|
||||||
|
management_addresses: ["10.0.0.2", "fe80::1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
r = %Types.LldpTopologyResult{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_id: @other_uuid,
|
||||||
|
local_system_name: "router-1",
|
||||||
|
neighbors: [neighbor],
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_lldp_topology_result(r)
|
||||||
|
assert {:ok, result} = Decode.decode_lldp_topology_result(binary)
|
||||||
|
assert result.device_id == @valid_uuid
|
||||||
|
assert result.job_id == @other_uuid
|
||||||
|
assert result.local_system_name == "router-1"
|
||||||
|
assert [n] = result.neighbors
|
||||||
|
assert n.neighbor_name == "switch-1"
|
||||||
|
assert n.local_port == "Gi0/1"
|
||||||
|
assert n.remote_port == "Gi0/24"
|
||||||
|
assert n.management_addresses == ["10.0.0.2", "fe80::1"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects lldp_topology_result with bad device_id" do
|
||||||
|
r = %Types.LldpTopologyResult{
|
||||||
|
device_id: "bad",
|
||||||
|
job_id: @other_uuid,
|
||||||
|
local_system_name: "x",
|
||||||
|
neighbors: [],
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_lldp_topology_result(r)
|
||||||
|
assert {:error, {:invalid_device_id, _}} = Decode.decode_lldp_topology_result(binary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects lldp_topology_result with bad job_id" do
|
||||||
|
r = %Types.LldpTopologyResult{
|
||||||
|
device_id: @valid_uuid,
|
||||||
|
job_id: "bad",
|
||||||
|
local_system_name: "x",
|
||||||
|
neighbors: [],
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_lldp_topology_result(r)
|
||||||
|
assert {:error, {:invalid_job_id, _}} = Decode.decode_lldp_topology_result(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "decode_check_result/1" do
|
||||||
|
test "decodes valid check_result round-trip" do
|
||||||
|
cr = %Types.CheckResult{
|
||||||
|
check_id: @third_uuid,
|
||||||
|
status: 1,
|
||||||
|
output: "All good",
|
||||||
|
response_time_ms: 12.3,
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_check_result(cr)
|
||||||
|
assert {:ok, result} = Decode.decode_check_result(binary)
|
||||||
|
assert result.check_id == @third_uuid
|
||||||
|
assert result.status == 1
|
||||||
|
assert result.output == "All good"
|
||||||
|
assert result.response_time_ms == 12.3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects check_result with bad check_id" do
|
||||||
|
cr = %Types.CheckResult{
|
||||||
|
check_id: "not-uuid",
|
||||||
|
status: 0,
|
||||||
|
output: "",
|
||||||
|
response_time_ms: 0.0,
|
||||||
|
timestamp: @valid_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
binary = Encode.encode_check_result(cr)
|
||||||
|
assert {:error, {:invalid_check_id, _}} = Decode.decode_check_result(binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -318,4 +318,57 @@ defmodule Towerops.Snmp.DiscoverySyncTest do
|
||||||
assert :ok = Discovery.save_arp_entries(snmp_device.device_id, [], [])
|
assert :ok = Discovery.save_arp_entries(snmp_device.device_id, [], [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "create_checks_from_discovery/2" do
|
||||||
|
@tag :skip
|
||||||
|
test "creates checks for interfaces, processors, and storage (sensor branch logs error due to missing sensor_class field)",
|
||||||
|
%{snmp_device: snmp_device} do
|
||||||
|
device = Repo.preload(snmp_device, :device).device
|
||||||
|
|
||||||
|
{:ok, _interface} =
|
||||||
|
Repo.insert(%Towerops.Snmp.Interface{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
if_index: 1,
|
||||||
|
if_name: "eth0",
|
||||||
|
if_descr: "Ethernet 0",
|
||||||
|
if_type: 6,
|
||||||
|
monitored: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _processor} =
|
||||||
|
Repo.insert(%Processor{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
processor_index: "1",
|
||||||
|
processor_type: "hr_processor",
|
||||||
|
description: "CPU 1"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _storage} =
|
||||||
|
Repo.insert(%Storage{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
storage_index: 1,
|
||||||
|
storage_type: "fixed_disk",
|
||||||
|
description: "Disk A",
|
||||||
|
total_bytes: 1000,
|
||||||
|
used_bytes: 500
|
||||||
|
})
|
||||||
|
|
||||||
|
result = Discovery.create_checks_from_discovery(device, snmp_device)
|
||||||
|
|
||||||
|
assert result.interfaces == 1
|
||||||
|
assert result.processors == 1
|
||||||
|
assert result.storage == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns zero counts when device has no discovery data",
|
||||||
|
%{snmp_device: snmp_device} do
|
||||||
|
device = Repo.preload(snmp_device, :device).device
|
||||||
|
result = Discovery.create_checks_from_discovery(device, snmp_device)
|
||||||
|
assert result.sensors == 0
|
||||||
|
assert result.interfaces == 0
|
||||||
|
assert result.processors == 0
|
||||||
|
assert result.storage == 0
|
||||||
|
assert result.errors == []
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
579
test/towerops_web/live/device_live/show_events_test.exs
Normal file
579
test/towerops_web/live/device_live/show_events_test.exs
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
defmodule ToweropsWeb.DeviceLive.ShowEventsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Additional coverage for DeviceLive.Show focused on:
|
||||||
|
|
||||||
|
* handle_event callbacks (checks CRUD, run_discovery, backups, dismiss_insight)
|
||||||
|
* Less-exercised handle_info branches (transceivers_updated,
|
||||||
|
printer_supplies_updated, hardware_inventory_updated, FormComponent
|
||||||
|
messages, agent_* messages with the matching agent_token_id)
|
||||||
|
* Tab parameter handling for backups (pagination), gaiia, preseem
|
||||||
|
"""
|
||||||
|
use ToweropsWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Towerops.Devices.MikrotikBackup
|
||||||
|
alias Towerops.Monitoring.Check
|
||||||
|
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||||
|
alias ToweropsWeb.CheckLive.FormComponent
|
||||||
|
|
||||||
|
setup do
|
||||||
|
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
|
||||||
|
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Events Org"}, user.id)
|
||||||
|
|
||||||
|
device =
|
||||||
|
Towerops.DevicesFixtures.device_fixture(%{
|
||||||
|
organization_id: organization.id,
|
||||||
|
name: "Events Router",
|
||||||
|
ip_address: "10.10.10.1"
|
||||||
|
})
|
||||||
|
|
||||||
|
%{user: user, organization: organization, device: device}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "checks tab handle_event" do
|
||||||
|
test "add_check shows the form modal", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
html = view |> element("button", "Add Check") |> render_click()
|
||||||
|
# show_check_form flips to true; modal should render (form-related text)
|
||||||
|
assert html =~ "Test Router" or html =~ "Events Router" or html =~ "Add" or html =~ "Check"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edit_check with non-existent id shows flash", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
fake_id = Ecto.UUID.generate()
|
||||||
|
html = render_hook(view, "edit_check", %{"id" => fake_id})
|
||||||
|
assert html =~ "Check not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_check with non-existent id shows flash", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
fake_id = Ecto.UUID.generate()
|
||||||
|
html = render_hook(view, "delete_check", %{"id" => fake_id})
|
||||||
|
assert html =~ "Check not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_check on real check removes it and shows success flash",
|
||||||
|
%{conn: conn, user: user, device: device, organization: organization} do
|
||||||
|
{:ok, check} =
|
||||||
|
Towerops.Monitoring.create_check(%{
|
||||||
|
name: "Manual Ping",
|
||||||
|
check_type: "ping",
|
||||||
|
organization_id: organization.id,
|
||||||
|
device_id: device.id,
|
||||||
|
config: %{"host" => "8.8.8.8"}
|
||||||
|
})
|
||||||
|
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
html = render_hook(view, "delete_check", %{"id" => check.id})
|
||||||
|
assert html =~ "Check deleted"
|
||||||
|
assert Towerops.Monitoring.get_check(check.id) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edit_check with real check populates the form", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
organization: organization
|
||||||
|
} do
|
||||||
|
{:ok, check} =
|
||||||
|
Towerops.Monitoring.create_check(%{
|
||||||
|
name: "Editable Ping",
|
||||||
|
check_type: "ping",
|
||||||
|
organization_id: organization.id,
|
||||||
|
device_id: device.id,
|
||||||
|
config: %{"host" => "1.1.1.1"}
|
||||||
|
})
|
||||||
|
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
html = render_hook(view, "edit_check", %{"id" => check.id})
|
||||||
|
# Editing a check sets show_check_form=true with the loaded check
|
||||||
|
assert html =~ "Editable Ping" or html =~ "Edit"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "discovery handle_event" do
|
||||||
|
test "run_discovery enqueues job and shows flash", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
html = render_hook(view, "run_discovery", %{})
|
||||||
|
assert html =~ "Discovery started"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "backups tab handle_event" do
|
||||||
|
setup %{device: device} do
|
||||||
|
backups =
|
||||||
|
for i <- 0..2 do
|
||||||
|
{:ok, backup} =
|
||||||
|
%MikrotikBackup{}
|
||||||
|
|> MikrotikBackup.changeset(%{
|
||||||
|
device_id: device.id,
|
||||||
|
config_compressed: :zlib.compress("/system identity\nset name=Backup#{i}\n"),
|
||||||
|
config_hash: "hash#{i}",
|
||||||
|
config_hash_normalized: "norm#{i}",
|
||||||
|
config_size_bytes: 100 + i,
|
||||||
|
compressed_size_bytes: 50 + i,
|
||||||
|
backed_up_at: DateTime.add(DateTime.utc_now(), -i * 60, :second),
|
||||||
|
trigger_source: "manual"
|
||||||
|
})
|
||||||
|
|> Towerops.Repo.insert()
|
||||||
|
|
||||||
|
backup
|
||||||
|
end
|
||||||
|
|
||||||
|
%{backups: backups}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders backups tab with backups present", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
assert html =~ "Events Router" or html =~ "Backup"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggle_backup_selection adds and removes a backup id", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
backups: [b1 | _]
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
# Toggle on
|
||||||
|
_ = render_hook(view, "toggle_backup_selection", %{"id" => b1.id})
|
||||||
|
# Toggle off (remove from set)
|
||||||
|
_ = render_hook(view, "toggle_backup_selection", %{"id" => b1.id})
|
||||||
|
|
||||||
|
# Should not crash; assert view still renders
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggle_backup_selection caps at 2 selections", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
backups: [b1, b2, b3]
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
_ = render_hook(view, "toggle_backup_selection", %{"id" => b1.id})
|
||||||
|
_ = render_hook(view, "toggle_backup_selection", %{"id" => b2.id})
|
||||||
|
# 3rd should be ignored (max 2)
|
||||||
|
_ = render_hook(view, "toggle_backup_selection", %{"id" => b3.id})
|
||||||
|
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "compare_selected with fewer than 2 backups shows error flash", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
html = render_hook(view, "compare_selected", %{})
|
||||||
|
assert html =~ "Please select exactly 2 backups"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clear_selection resets selected backup ids", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
backups: [b1 | _]
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
_ = render_hook(view, "toggle_backup_selection", %{"id" => b1.id})
|
||||||
|
html = render_hook(view, "clear_selection", %{})
|
||||||
|
assert html =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "download_backup pushes a download event", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
backups: [b1 | _]
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
_ = render_hook(view, "download_backup", %{"id" => b1.id})
|
||||||
|
# If the handler runs without crashing the view is still alive.
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_backup as non-owner is rejected with permission flash", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
backups: [b1 | _]
|
||||||
|
} do
|
||||||
|
# Use a fresh user that is *not* the org owner
|
||||||
|
stranger = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
|
||||||
|
{:ok, _org} = Towerops.Organizations.create_organization(%{name: "Stranger Org"}, stranger.id)
|
||||||
|
|
||||||
|
# The owner of the org with the device is in the test setup; we need to
|
||||||
|
# use a member-only path. Easier: log in as the owner so the org-owner
|
||||||
|
# check passes and we exercise the delete_backup non-owner branch with a
|
||||||
|
# different scenario — instead, test the "no-permission" path by
|
||||||
|
# attempting to delete a backup belonging to a *different* org's device.
|
||||||
|
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
|
||||||
|
|
||||||
|
{:ok, other_org} =
|
||||||
|
Towerops.Organizations.create_organization(%{name: "Other Org #{System.unique_integer()}"}, other_user.id)
|
||||||
|
|
||||||
|
_other_device =
|
||||||
|
Towerops.DevicesFixtures.device_fixture(%{
|
||||||
|
organization_id: other_org.id,
|
||||||
|
name: "Other Router"
|
||||||
|
})
|
||||||
|
|
||||||
|
conn = log_in_user(conn, other_user)
|
||||||
|
|
||||||
|
# Visiting the original device should redirect (no access)
|
||||||
|
assert {:error, {:live_redirect, _}} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
_ = b1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "backup_now without agent shows error flash", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
|
||||||
|
html = render_hook(view, "backup_now", %{})
|
||||||
|
assert html =~ "no agent assigned" or html =~ "MikroTik" or html =~ "MikroTik API is not enabled"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "backups tab pagination handles ?page param", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=backups&page=1")
|
||||||
|
|
||||||
|
# Should render without crashing and apply pagination map
|
||||||
|
assert html =~ "Events Router" or html =~ "Backup"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "backups tab pagination clamps invalid page numbers", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
# Negative / huge page numbers must not crash
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=backups&page=99999")
|
||||||
|
assert html =~ "Events Router" or html =~ "Backup"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "backups tab pagination handles non-numeric page (safe_to_integer)", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=backups&page=notanumber")
|
||||||
|
assert html =~ "Events Router" or html =~ "Backup"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "preseem and gaiia tabs" do
|
||||||
|
test "renders preseem tab", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=preseem")
|
||||||
|
assert html =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders gaiia tab", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=gaiia")
|
||||||
|
assert html =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "dismiss_insight with non-existent id renders error flash", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=preseem")
|
||||||
|
|
||||||
|
fake_id = Ecto.UUID.generate()
|
||||||
|
html = render_hook(view, "dismiss_insight", %{"id" => fake_id})
|
||||||
|
assert html =~ "Failed to dismiss insight" or html =~ "Events Router"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_info: SNMP-driven updates" do
|
||||||
|
test "transceivers_updated reloads when on transceivers tab", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
%SnmpDevice{}
|
||||||
|
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "sw", sys_descr: "Sw"})
|
||||||
|
|> Towerops.Repo.insert!()
|
||||||
|
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=transceivers")
|
||||||
|
|
||||||
|
send(view.pid, {:transceivers_updated, device.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "printer_supplies_updated reloads when on printer_supplies tab", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
%SnmpDevice{}
|
||||||
|
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "p", sys_descr: "Pr"})
|
||||||
|
|> Towerops.Repo.insert!()
|
||||||
|
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=printer_supplies")
|
||||||
|
|
||||||
|
send(view.pid, {:printer_supplies_updated, device.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hardware_inventory_updated reloads when on hardware tab", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
%SnmpDevice{}
|
||||||
|
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "h", sys_descr: "Hw"})
|
||||||
|
|> Towerops.Repo.insert!()
|
||||||
|
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=hardware")
|
||||||
|
|
||||||
|
send(view.pid, {:hardware_inventory_updated, device.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transceivers_updated is ignored when on a different tab", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:transceivers_updated, device.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "printer_supplies_updated is ignored when on a different tab", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:printer_supplies_updated, device.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hardware_inventory_updated is ignored when on a different tab", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:hardware_inventory_updated, device.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "alert_changed message is ignored gracefully", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:alert_changed, Ecto.UUID.generate()})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown handle_info messages are ignored gracefully", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:totally_unknown_message, :foo})
|
||||||
|
send(view.pid, :random_atom_message)
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_info: agent updates with matching agent_token_id" do
|
||||||
|
setup %{organization: organization, device: device} do
|
||||||
|
{:ok, agent_token, _plain_token} =
|
||||||
|
Towerops.Agents.create_agent_token(organization.id, "Events Agent #{System.unique_integer()}")
|
||||||
|
|
||||||
|
{:ok, _assignment} = Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||||
|
%{agent_token: agent_token}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "agent_connected with matching token reloads base data", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
organization: organization,
|
||||||
|
agent_token: agent_token
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:agent_connected, agent_token.id, organization.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "agent_disconnected with matching token reloads base data", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
organization: organization,
|
||||||
|
agent_token: agent_token
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:agent_disconnected, agent_token.id, organization.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "agent_heartbeat with matching token reloads base data", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
organization: organization,
|
||||||
|
agent_token: agent_token
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:agent_heartbeat, agent_token.id, organization.id})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "agents_stale containing this device's agent reloads base data", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device,
|
||||||
|
agent_token: agent_token
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
send(view.pid, {:agents_stale, [%{id: agent_token.id}]})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_info: FormComponent messages" do
|
||||||
|
test ":close clears the form state", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
send(view.pid, {FormComponent, :close})
|
||||||
|
assert render(view) =~ "Events Router"
|
||||||
|
end
|
||||||
|
|
||||||
|
test ":check_created reloads checks and shows flash", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
send(view.pid, {FormComponent, {:check_created, %Check{id: Ecto.UUID.generate()}}})
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "Check created successfully"
|
||||||
|
end
|
||||||
|
|
||||||
|
test ":check_updated reloads checks and shows flash", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||||
|
|
||||||
|
send(view.pid, {FormComponent, {:check_updated, %Check{id: Ecto.UUID.generate()}}})
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "Check updated successfully"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tab switching via patch" do
|
||||||
|
test "switching to checks tab via patch updates URL and shows checks", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
|
||||||
|
|
||||||
|
# Click any tab link that exists - the Checks link is always rendered
|
||||||
|
html = view |> element("a", "Checks") |> render_click()
|
||||||
|
assert html =~ "Add Check" or html =~ "Check"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "navigating directly to backups tab works", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=backups")
|
||||||
|
assert html =~ "Events Router"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "set_capacity / clear_capacity error branches" do
|
||||||
|
setup %{device: device} do
|
||||||
|
snmp_device =
|
||||||
|
%SnmpDevice{}
|
||||||
|
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "x", sys_descr: "X"})
|
||||||
|
|> Towerops.Repo.insert!()
|
||||||
|
|
||||||
|
%{snmp_device: snmp_device}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "set_capacity rejects invalid value", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_hook(view, "set_capacity", %{
|
||||||
|
"interface_id" => Ecto.UUID.generate(),
|
||||||
|
"capacity_mbps" => "not-a-number"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ "Invalid capacity value"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "set_capacity rejects zero / negative value", %{conn: conn, user: user, device: device} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_hook(view, "set_capacity", %{
|
||||||
|
"interface_id" => Ecto.UUID.generate(),
|
||||||
|
"capacity_mbps" => "0"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ "Invalid capacity value"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
602
test/towerops_web/live/graph_live/show_events_test.exs
Normal file
602
test/towerops_web/live/graph_live/show_events_test.exs
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
defmodule ToweropsWeb.GraphLive.ShowEventsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Additional coverage for `ToweropsWeb.GraphLive.Show` focusing on:
|
||||||
|
* check-based graph paths (`check_id`)
|
||||||
|
* range switching across all valid ranges
|
||||||
|
* handle_info handlers (live_poll, sensor/neighbor/status events)
|
||||||
|
* terminate cleanup
|
||||||
|
* less-common sensor types (voltage, storage volume, count, wireless, unknown)
|
||||||
|
* access control / mismatched check_device
|
||||||
|
"""
|
||||||
|
use ToweropsWeb.ConnCase
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Towerops.Monitoring
|
||||||
|
alias Towerops.Monitoring.Check
|
||||||
|
alias Towerops.Monitoring.CheckResult
|
||||||
|
alias Towerops.Organizations
|
||||||
|
alias Towerops.Repo
|
||||||
|
alias Towerops.Sites
|
||||||
|
alias Towerops.Snmp
|
||||||
|
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||||
|
alias Towerops.Snmp.Interface
|
||||||
|
alias Towerops.Snmp.Sensor
|
||||||
|
alias Towerops.Snmp.Storage
|
||||||
|
alias Towerops.Snmp.StorageReading
|
||||||
|
|
||||||
|
setup :register_and_log_in_user
|
||||||
|
|
||||||
|
setup %{user: user} do
|
||||||
|
{:ok, organization} = Organizations.create_organization(%{name: "Events Org"}, user.id)
|
||||||
|
|
||||||
|
{:ok, site} =
|
||||||
|
Sites.create_site(%{name: "Events Site", organization_id: organization.id})
|
||||||
|
|
||||||
|
{:ok, device} =
|
||||||
|
Towerops.Devices.create_device(%{
|
||||||
|
name: "Events Router",
|
||||||
|
ip_address: "192.168.50.1",
|
||||||
|
site_id: site.id,
|
||||||
|
organization_id: organization.id,
|
||||||
|
snmp_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
snmp_device =
|
||||||
|
%SnmpDevice{}
|
||||||
|
|> SnmpDevice.changeset(%{
|
||||||
|
device_id: device.id,
|
||||||
|
sys_name: "events-router",
|
||||||
|
sys_descr: "Events Router",
|
||||||
|
sys_object_id: "1.3.6.1.4.1.9"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%{organization: organization, site: site, device: device, snmp_device: snmp_device}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_config_for("http"), do: %{"url" => "https://example.com"}
|
||||||
|
defp default_config_for("tcp"), do: %{"host" => "example.com", "port" => 80}
|
||||||
|
defp default_config_for("dns"), do: %{"hostname" => "example.com"}
|
||||||
|
defp default_config_for("ssl"), do: %{"host" => "example.com"}
|
||||||
|
defp default_config_for("ping"), do: %{"host" => "example.com"}
|
||||||
|
defp default_config_for(_), do: %{}
|
||||||
|
|
||||||
|
defp insert_check!(device, attrs) do
|
||||||
|
check_type = Map.get(attrs, :check_type, "http")
|
||||||
|
config = Map.get(attrs, :config, default_config_for(check_type))
|
||||||
|
|
||||||
|
base = %{
|
||||||
|
device_id: device.id,
|
||||||
|
organization_id: device.organization_id,
|
||||||
|
name: "Test Check",
|
||||||
|
check_type: check_type,
|
||||||
|
enabled: true,
|
||||||
|
interval_seconds: 60,
|
||||||
|
config: config
|
||||||
|
}
|
||||||
|
|
||||||
|
%Check{}
|
||||||
|
|> Check.changeset(Map.merge(base, attrs))
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp insert_check_result!(check, attrs) do
|
||||||
|
base = %{
|
||||||
|
check_id: check.id,
|
||||||
|
organization_id: check.organization_id,
|
||||||
|
checked_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||||
|
status: 0,
|
||||||
|
value: 12.5
|
||||||
|
}
|
||||||
|
|
||||||
|
%CheckResult{}
|
||||||
|
|> CheckResult.changeset(Map.merge(base, attrs))
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "check-based graph (check_id route)" do
|
||||||
|
test "renders http check graph with results", %{conn: conn, device: device} do
|
||||||
|
check = insert_check!(device, %{name: "HTTP up", check_type: "http"})
|
||||||
|
insert_check_result!(check, %{value: 25.4})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
assert html =~ "HTTP up"
|
||||||
|
assert html =~ device.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders dns check graph (dual axis with status)", %{conn: conn, device: device} do
|
||||||
|
check = insert_check!(device, %{name: "DNS lookup", check_type: "dns"})
|
||||||
|
insert_check_result!(check, %{status: 0, value: 8.2})
|
||||||
|
insert_check_result!(check, %{status: 2, value: nil})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
assert html =~ "DNS lookup"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders dns check graph with no results (empty path)", %{conn: conn, device: device} do
|
||||||
|
check = insert_check!(device, %{name: "Empty DNS", check_type: "dns"})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
assert html =~ "Empty DNS"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders snmp_processor check graph (no auto scale)", %{conn: conn, device: device} do
|
||||||
|
check = insert_check!(device, %{name: "CPU check", check_type: "snmp_processor"})
|
||||||
|
insert_check_result!(check, %{value: 55.0})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
assert html =~ "CPU check"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders snmp_sensor check using sensor_unit from config", %{conn: conn, device: device} do
|
||||||
|
check =
|
||||||
|
insert_check!(device, %{
|
||||||
|
name: "Sensor C",
|
||||||
|
check_type: "snmp_sensor",
|
||||||
|
config: %{"sensor_unit" => "Celsius"}
|
||||||
|
})
|
||||||
|
|
||||||
|
insert_check_result!(check, %{value: 30.0})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
assert html =~ "Sensor C"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects when check is not found", %{conn: conn, device: device} do
|
||||||
|
fake_check_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: target}}} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{fake_check_id}")
|
||||||
|
|
||||||
|
assert target =~ "/devices/#{device.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects when check belongs to different device", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
organization: organization,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
{:ok, other_device} =
|
||||||
|
Towerops.Devices.create_device(%{
|
||||||
|
name: "Other",
|
||||||
|
ip_address: "10.0.0.2",
|
||||||
|
site_id: site.id,
|
||||||
|
organization_id: organization.id,
|
||||||
|
snmp_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
check = insert_check!(other_device, %{name: "Other Check"})
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: target}}} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
assert target =~ "/devices/#{device.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "honors range param on check graph", %{conn: conn, device: device} do
|
||||||
|
check = insert_check!(device, %{name: "HTTP w/ range", check_type: "http"})
|
||||||
|
insert_check_result!(check, %{value: 10.0})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}&range=7d")
|
||||||
|
|
||||||
|
assert html =~ "7 Days"
|
||||||
|
assert html =~ "HTTP w/ range"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_range on check graph patches with check_id preserved", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
check = insert_check!(device, %{name: "Range Patch", check_type: "http"})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("button", "1 Hour")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "1 Hour"
|
||||||
|
assert html =~ "Range Patch"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Range switching" do
|
||||||
|
test "supports all valid ranges via URL parameter", %{conn: conn, device: device} do
|
||||||
|
cases = [
|
||||||
|
{"1h", "1 Hour"},
|
||||||
|
{"6h", "6 Hours"},
|
||||||
|
{"12h", "12 Hours"},
|
||||||
|
{"24h", "24 Hours"},
|
||||||
|
{"7d", "7 Days"},
|
||||||
|
{"30d", "30 Days"}
|
||||||
|
]
|
||||||
|
|
||||||
|
for {range, label} <- cases do
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/latency?range=#{range}")
|
||||||
|
|
||||||
|
assert html =~ label, "expected range=#{range} to render '#{label}'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown range falls back gracefully (defaults to 24h behavior)", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/latency?range=bogus")
|
||||||
|
|
||||||
|
# Page renders without crashing
|
||||||
|
assert html =~ device.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_range click for 7d updates label", %{conn: conn, device: device} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("button", "7 Days")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "7 Days"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_range preserves sensor_id when present", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
snmp_device: snmp_device
|
||||||
|
} do
|
||||||
|
sensor =
|
||||||
|
%Sensor{}
|
||||||
|
|> Sensor.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
sensor_type: "voltage",
|
||||||
|
sensor_index: "1",
|
||||||
|
sensor_oid: "1.3.6.1.4.1.9.9.13.1.2.1.3.1",
|
||||||
|
sensor_descr: "PSU1 V",
|
||||||
|
sensor_unit: "V"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
Snmp.create_sensor_reading(%{
|
||||||
|
sensor_id: sensor.id,
|
||||||
|
value: 12.0,
|
||||||
|
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/voltage?sensor_id=#{sensor.id}")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("button", "1 Hour")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "1 Hour"
|
||||||
|
assert html =~ "PSU1 V"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_range preserves storage_id when present", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
snmp_device: snmp_device
|
||||||
|
} do
|
||||||
|
storage =
|
||||||
|
%Storage{}
|
||||||
|
|> Storage.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
storage_index: 1,
|
||||||
|
description: "/",
|
||||||
|
storage_type: "fixed_disk",
|
||||||
|
total_bytes: 1_000_000,
|
||||||
|
used_bytes: 500_000
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%StorageReading{}
|
||||||
|
|> StorageReading.changeset(%{
|
||||||
|
storage_id: storage.id,
|
||||||
|
used_bytes: 500_000,
|
||||||
|
total_bytes: 1_000_000,
|
||||||
|
usage_percent: 50.0,
|
||||||
|
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/storage_volume?storage_id=#{storage.id}")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("button", "6 Hours")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "6 Hours"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Sensor type rendering" do
|
||||||
|
test "renders voltage chart config", %{conn: conn, device: device} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/voltage")
|
||||||
|
assert html =~ "Voltage"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders count chart config", %{conn: conn, device: device} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/count")
|
||||||
|
assert html =~ "Count"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders pppoe_sessions chart config", %{conn: conn, device: device} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/pppoe_sessions")
|
||||||
|
assert html =~ "PPPoE Sessions"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders connections chart config", %{conn: conn, device: device} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/connections")
|
||||||
|
assert html =~ "Connections"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders wireless chart config", %{conn: conn, device: device} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/wireless")
|
||||||
|
assert html =~ "Wireless Metrics"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "humanizes unknown sensor_type", %{conn: conn, device: device} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/foo_bar_baz")
|
||||||
|
assert html =~ "Foo Bar Baz"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders storage_volume graph for given storage", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
snmp_device: snmp_device
|
||||||
|
} do
|
||||||
|
storage =
|
||||||
|
%Storage{}
|
||||||
|
|> Storage.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
storage_index: 2,
|
||||||
|
description: "/data",
|
||||||
|
storage_type: "fixed_disk",
|
||||||
|
total_bytes: 10_000,
|
||||||
|
used_bytes: 2_000
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
%StorageReading{}
|
||||||
|
|> StorageReading.changeset(%{
|
||||||
|
storage_id: storage.id,
|
||||||
|
used_bytes: 2_000,
|
||||||
|
total_bytes: 10_000,
|
||||||
|
usage_percent: 20.0,
|
||||||
|
checked_at: now
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/storage_volume?storage_id=#{storage.id}")
|
||||||
|
|
||||||
|
assert html =~ "Storage Usage"
|
||||||
|
assert html =~ "/data"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_info / live polling" do
|
||||||
|
test "device_status_changed reloads device assign", %{conn: conn, device: device} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
|
||||||
|
|
||||||
|
send(view.pid, {:device_status_changed, device.id, :down, 9999})
|
||||||
|
|
||||||
|
# Process should still be alive
|
||||||
|
assert Process.alive?(view.pid)
|
||||||
|
assert render(view) =~ device.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "state_sensors_updated triggers reload without crashing", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
snmp_device: snmp_device
|
||||||
|
} do
|
||||||
|
sensor =
|
||||||
|
%Sensor{}
|
||||||
|
|> Sensor.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
sensor_type: "cpu_load",
|
||||||
|
sensor_index: "1",
|
||||||
|
sensor_oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.8.1",
|
||||||
|
sensor_descr: "CPU 0",
|
||||||
|
sensor_unit: "%"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
Snmp.create_sensor_reading(%{
|
||||||
|
sensor_id: sensor.id,
|
||||||
|
value: 30.0,
|
||||||
|
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/processors")
|
||||||
|
|
||||||
|
send(view.pid, {:state_sensors_updated, device.id})
|
||||||
|
|
||||||
|
assert render(view) =~ "Processor Usage"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "neighbors_updated is a no-op", %{conn: conn, device: device} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
|
||||||
|
|
||||||
|
send(view.pid, {:neighbors_updated, device.id})
|
||||||
|
assert render(view) =~ device.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "live_poll while not in live mode is ignored", %{conn: conn, device: device} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
|
||||||
|
|
||||||
|
send(view.pid, :live_poll)
|
||||||
|
assert render(view) =~ device.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unrecognized info message is ignored (catch-all)", %{conn: conn, device: device} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
|
||||||
|
|
||||||
|
send(view.pid, :something_unexpected)
|
||||||
|
assert render(view) =~ device.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "terminate cancels any live polling timer", %{conn: conn, device: device} do
|
||||||
|
Process.flag(:trap_exit, true)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
|
||||||
|
|
||||||
|
pid = view.pid
|
||||||
|
assert Process.alive?(pid)
|
||||||
|
|
||||||
|
# cancel_live_polling_timer/1 must safely handle the case where no timer
|
||||||
|
# was set (the latency graph isn't in live mode). terminate/2 calls it.
|
||||||
|
ref = Process.monitor(pid)
|
||||||
|
GenServer.stop(pid, :shutdown, 1_000)
|
||||||
|
|
||||||
|
assert_receive {:DOWN, ^ref, :process, _, _}, 1_000
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "access control extras" do
|
||||||
|
test "redirects when device belongs to a different organization", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user
|
||||||
|
} do
|
||||||
|
# Create a separate org/site/device the current user does not belong to
|
||||||
|
other_user = Towerops.AccountsFixtures.user_fixture()
|
||||||
|
|
||||||
|
{:ok, other_org} =
|
||||||
|
Organizations.create_organization(%{name: "Other Org"}, other_user.id)
|
||||||
|
|
||||||
|
{:ok, other_site} =
|
||||||
|
Sites.create_site(%{name: "Other Site", organization_id: other_org.id})
|
||||||
|
|
||||||
|
{:ok, foreign_device} =
|
||||||
|
Towerops.Devices.create_device(%{
|
||||||
|
name: "Forbidden",
|
||||||
|
ip_address: "10.20.30.40",
|
||||||
|
site_id: other_site.id,
|
||||||
|
organization_id: other_org.id,
|
||||||
|
snmp_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = user
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: "/devices"}}} =
|
||||||
|
live(conn, ~p"/devices/#{foreign_device.id}/graph/latency")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects when sensor-route device id is not found", %{conn: conn} do
|
||||||
|
fake_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: "/devices"}}} =
|
||||||
|
live(conn, ~p"/devices/#{fake_id}/graph/processors")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "interface_errors graph data shapes" do
|
||||||
|
setup %{snmp_device: snmp_device} do
|
||||||
|
interface =
|
||||||
|
%Interface{}
|
||||||
|
|> Interface.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
if_index: 2,
|
||||||
|
if_descr: "eth1",
|
||||||
|
if_name: "eth1",
|
||||||
|
if_oper_status: "up",
|
||||||
|
if_speed: 1_000_000_000
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%{interface: interface}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes error rate-of-change between two stats", %{
|
||||||
|
conn: conn,
|
||||||
|
device: device,
|
||||||
|
interface: interface
|
||||||
|
} do
|
||||||
|
base = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
Snmp.create_interface_stat(%{
|
||||||
|
interface_id: interface.id,
|
||||||
|
if_in_errors: 0,
|
||||||
|
if_out_errors: 0,
|
||||||
|
if_in_discards: 0,
|
||||||
|
if_out_discards: 0,
|
||||||
|
checked_at: DateTime.add(base, -60, :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
Snmp.create_interface_stat(%{
|
||||||
|
interface_id: interface.id,
|
||||||
|
if_in_errors: 12,
|
||||||
|
if_out_errors: 6,
|
||||||
|
if_in_discards: 3,
|
||||||
|
if_out_discards: 0,
|
||||||
|
checked_at: base
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, ~p"/devices/#{device.id}/graph/interface_errors?interface_id=#{interface.id}")
|
||||||
|
|
||||||
|
assert html =~ "Interface Errors & Discards"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "monitoring helper coverage" do
|
||||||
|
test "get_check_graph_data returns rows for snmp_sensor with source backfill path", %{
|
||||||
|
device: device,
|
||||||
|
snmp_device: snmp_device
|
||||||
|
} do
|
||||||
|
sensor =
|
||||||
|
%Sensor{}
|
||||||
|
|> Sensor.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
sensor_type: "temperature",
|
||||||
|
sensor_index: "1",
|
||||||
|
sensor_oid: "1.3.6.1.4.1.1",
|
||||||
|
sensor_descr: "Temp1",
|
||||||
|
sensor_unit: "C"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
check =
|
||||||
|
insert_check!(device, %{
|
||||||
|
name: "SNMP backfill",
|
||||||
|
check_type: "snmp_sensor",
|
||||||
|
source_id: sensor.id,
|
||||||
|
config: %{"sensor_unit" => "Celsius"}
|
||||||
|
})
|
||||||
|
|
||||||
|
Snmp.create_sensor_reading(%{
|
||||||
|
sensor_id: sensor.id,
|
||||||
|
value: 22.5,
|
||||||
|
checked_at: DateTime.add(DateTime.utc_now(), -1, :hour)
|
||||||
|
})
|
||||||
|
|
||||||
|
from = DateTime.add(DateTime.utc_now(), -2, :hour)
|
||||||
|
to = DateTime.utc_now()
|
||||||
|
points = Monitoring.get_check_graph_data(check.id, from, to)
|
||||||
|
assert is_list(points)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
349
test/towerops_web/live/org/settings_live_more_events_test.exs
Normal file
349
test/towerops_web/live/org/settings_live_more_events_test.exs
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
defmodule ToweropsWeb.Org.SettingsLiveMoreEventsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Additional handle_event coverage for OrgSettingsLive — focuses on
|
||||||
|
branches not driven by other test files: invitation/member error
|
||||||
|
paths, billing portal/checkout, permission denials, and
|
||||||
|
toggle/save flows for existing integrations.
|
||||||
|
"""
|
||||||
|
use ToweropsWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import Towerops.AccountsFixtures
|
||||||
|
import Towerops.IntegrationsFixtures
|
||||||
|
import Towerops.OrganizationsFixtures
|
||||||
|
|
||||||
|
alias Towerops.Organizations
|
||||||
|
|
||||||
|
setup do
|
||||||
|
user = user_fixture(enable_totp: true)
|
||||||
|
organization = organization_fixture(user.id)
|
||||||
|
%{user: user, organization: organization}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "cancel_invitation event" do
|
||||||
|
test "flashes 'not found' when given an unknown id",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "cancel_invitation", %{"id" => Ecto.UUID.generate()})
|
||||||
|
assert html =~ "Invitation not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "succeeds for a real invitation",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, invitation} =
|
||||||
|
Organizations.create_invitation(%{
|
||||||
|
email: "real@example.com",
|
||||||
|
role: :technician,
|
||||||
|
organization_id: org.id,
|
||||||
|
invited_by_id: user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "cancel_invitation", %{"id" => invitation.id})
|
||||||
|
assert html =~ "Invitation cancelled"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "remove_member event" do
|
||||||
|
test "cannot remove the owner",
|
||||||
|
%{conn: conn, user: owner, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(owner)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "remove_member", %{"user-id" => owner.id})
|
||||||
|
assert html =~ "Cannot remove the organization owner"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "removes a non-owner member",
|
||||||
|
%{conn: conn, user: owner, organization: org} do
|
||||||
|
member = user_fixture(enable_totp: true)
|
||||||
|
_ = membership_fixture(org.id, member.id, :technician)
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(owner)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "remove_member", %{"user-id" => member.id})
|
||||||
|
assert html =~ "Member removed"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "change_role event" do
|
||||||
|
test "cannot change the owner's role",
|
||||||
|
%{conn: conn, user: owner, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(owner)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "change_role", %{"user-id" => owner.id, "role" => "admin"})
|
||||||
|
assert html =~ "Cannot change the owner's role"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates the role of a non-owner member",
|
||||||
|
%{conn: conn, user: owner, organization: org} do
|
||||||
|
member = user_fixture(enable_totp: true)
|
||||||
|
_ = membership_fixture(org.id, member.id, :technician)
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(owner)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "change_role", %{"user-id" => member.id, "role" => "admin"})
|
||||||
|
assert html =~ "Role updated"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "toggle_default_org event" do
|
||||||
|
test "flashes already-default when membership is the default",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
# The owner-created org's membership defaults to is_default: true
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings")
|
||||||
|
|
||||||
|
html = render_hook(view, "toggle_default_org", %{})
|
||||||
|
assert html =~ "already your default organization"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flips default when current membership is not the default",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
_other_org = organization_fixture(user.id)
|
||||||
|
membership = Organizations.get_membership!(org.id, user.id)
|
||||||
|
{:ok, _} = Organizations.update_membership(membership, %{is_default: false})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings")
|
||||||
|
|
||||||
|
html = render_hook(view, "toggle_default_org", %{})
|
||||||
|
assert html =~ "is now your default organization"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "send_invitation event errors" do
|
||||||
|
test "flashes error for invalid email",
|
||||||
|
%{conn: conn, user: owner, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(owner)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
# blank email triggers validation error in Organizations.create_invitation
|
||||||
|
html = render_hook(view, "send_invitation", %{"email" => "", "role" => "technician"})
|
||||||
|
assert html =~ "Failed to send invitation"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "permission-denied paths (viewer role)" do
|
||||||
|
setup %{organization: org} do
|
||||||
|
viewer = user_fixture(enable_totp: true)
|
||||||
|
_membership = membership_fixture(org.id, viewer.id, :viewer)
|
||||||
|
%{viewer: viewer}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewer cannot apply SNMP to all",
|
||||||
|
%{conn: conn, viewer: viewer, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(viewer)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings")
|
||||||
|
|
||||||
|
html = render_hook(view, "apply_snmp_to_all", %{})
|
||||||
|
assert html =~ "don't have permission" or html =~ "don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewer cannot apply default agent to all",
|
||||||
|
%{conn: conn, viewer: viewer, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(viewer)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings")
|
||||||
|
|
||||||
|
html = render_hook(view, "apply_agent_to_all", %{})
|
||||||
|
assert html =~ "don't have permission" or html =~ "don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewer cannot save organization changes",
|
||||||
|
%{conn: conn, viewer: viewer, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(viewer)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings")
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_hook(view, "save", %{"organization" => %{"name" => "New Name"}})
|
||||||
|
|
||||||
|
assert html =~ "don't have permission" or html =~ "don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewer cannot send invitations",
|
||||||
|
%{conn: conn, viewer: viewer, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(viewer)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "send_invitation", %{"email" => "x@x.com", "role" => "technician"})
|
||||||
|
assert html =~ "don't have permission" or html =~ "don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewer cannot remove members",
|
||||||
|
%{conn: conn, viewer: viewer, user: owner, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(viewer)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html = render_hook(view, "remove_member", %{"user-id" => owner.id})
|
||||||
|
assert html =~ "don't have permission" or html =~ "don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewer cannot change roles",
|
||||||
|
%{conn: conn, viewer: viewer, user: owner, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(viewer)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_hook(view, "change_role", %{"user-id" => owner.id, "role" => "admin"})
|
||||||
|
|
||||||
|
assert html =~ "don't have permission" or html =~ "don't have permission"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "manage_billing event" do
|
||||||
|
test "flashes friendly error when org has no stripe customer",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=billing")
|
||||||
|
|
||||||
|
html = render_hook(view, "manage_billing", %{})
|
||||||
|
assert html =~ "No billing account found"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "toggle_enabled with existing integration" do
|
||||||
|
test "toggles a real integration off and on",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
integration = integration_fixture(org.id, %{provider: "preseem", enabled: true})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=general")
|
||||||
|
|
||||||
|
_ = render_hook(view, "toggle_enabled", %{"provider" => "preseem"})
|
||||||
|
|
||||||
|
# Verify the integration was actually toggled in the DB
|
||||||
|
{:ok, reloaded} = Towerops.Integrations.get_integration_by_id(integration.id)
|
||||||
|
assert reloaded.enabled == false
|
||||||
|
|
||||||
|
# Toggle back on
|
||||||
|
_ = render_hook(view, "toggle_enabled", %{"provider" => "preseem"})
|
||||||
|
{:ok, reloaded2} = Towerops.Integrations.get_integration_by_id(integration.id)
|
||||||
|
assert reloaded2.enabled == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no-ops when provider integration doesn't exist",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=general")
|
||||||
|
|
||||||
|
# No integration exists for "preseem" — handler should no-op (no flash)
|
||||||
|
html = render_hook(view, "toggle_enabled", %{"provider" => "preseem"})
|
||||||
|
refute html =~ "Integration updated"
|
||||||
|
refute html =~ "Failed to update"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "save_webhook_secret with existing gaiia integration" do
|
||||||
|
test "persists the secret on the integration",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
integration = integration_fixture(org.id, %{provider: "gaiia"})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=general")
|
||||||
|
|
||||||
|
_ = render_hook(view, "save_webhook_secret", %{"value" => " super-secret "})
|
||||||
|
|
||||||
|
{:ok, reloaded} = Towerops.Integrations.get_integration_by_id(integration.id)
|
||||||
|
assert reloaded.credentials["webhook_secret"] == "super-secret"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "save_gaiia_app_url with existing gaiia integration" do
|
||||||
|
test "persists the URL on the integration",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
integration = integration_fixture(org.id, %{provider: "gaiia"})
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=general")
|
||||||
|
|
||||||
|
_ =
|
||||||
|
render_hook(view, "save_gaiia_app_url", %{"value" => " https://gaiia.example.com "})
|
||||||
|
|
||||||
|
{:ok, reloaded} = Towerops.Integrations.get_integration_by_id(integration.id)
|
||||||
|
assert reloaded.credentials["app_url"] == "https://gaiia.example.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "save_webhook_secret / save_gaiia_app_url no-ops" do
|
||||||
|
test "save_webhook_secret no-ops when no gaiia integration exists",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=general")
|
||||||
|
|
||||||
|
html = render_hook(view, "save_webhook_secret", %{"value" => "x"})
|
||||||
|
refute html =~ "Webhook secret saved"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "save_gaiia_app_url no-ops when no gaiia integration exists",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=general")
|
||||||
|
|
||||||
|
html = render_hook(view, "save_gaiia_app_url", %{"value" => "https://x.example"})
|
||||||
|
refute html =~ "Gaiia app URL saved"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "billing tab rendering" do
|
||||||
|
test "renders billing tab without error",
|
||||||
|
%{conn: conn, user: user, organization: org} do
|
||||||
|
{:ok, _view, html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(user)
|
||||||
|
|> live(~p"/orgs/#{org.slug}/settings?tab=billing")
|
||||||
|
|
||||||
|
assert html =~ "Organization Settings"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue