add VLAN schema, migration, and discovery
This commit is contained in:
parent
c3e26a44d6
commit
579a7bac21
5 changed files with 584 additions and 0 deletions
|
|
@ -71,6 +71,12 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
ent_state_oper: "1.3.6.1.2.1.131.1.1.1.1"
|
||||
}
|
||||
|
||||
@q_bridge_oids %{
|
||||
# Q-BRIDGE-MIB - IEEE 802.1Q VLAN discovery
|
||||
dot1q_vlan_static_name: "1.3.6.1.2.1.17.7.1.4.3.1.1",
|
||||
dot1q_vlan_static_row_status: "1.3.6.1.2.1.17.7.1.4.3.1.5"
|
||||
}
|
||||
|
||||
# ENTITY-MIB physical class values to track for state sensors
|
||||
# 6 = powerSupply, 7 = fan
|
||||
@state_sensor_classes [6, 7]
|
||||
|
|
@ -262,6 +268,92 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
{:ok, state_sensors}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Discovers VLANs from Q-BRIDGE-MIB.
|
||||
Returns a list of VLAN maps with vlan_id, vlan_name, and status.
|
||||
"""
|
||||
@spec discover_vlans(Client.connection_opts()) :: {:ok, [map()]}
|
||||
def discover_vlans(client_opts) do
|
||||
# Walk VLAN names from Q-BRIDGE-MIB
|
||||
case Client.walk(client_opts, @q_bridge_oids.dot1q_vlan_static_name) do
|
||||
{:ok, name_results} when is_map(name_results) and map_size(name_results) > 0 ->
|
||||
do_discover_vlans(client_opts, name_results)
|
||||
|
||||
{:ok, _} ->
|
||||
# No VLANs found via name walk, try status walk
|
||||
try_vlan_status_walk(client_opts)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("Q-BRIDGE-MIB VLAN name walk failed: #{inspect(reason)}")
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_discover_vlans(client_opts, name_results) do
|
||||
# Fetch VLAN status
|
||||
status_map =
|
||||
case Client.walk(client_opts, @q_bridge_oids.dot1q_vlan_static_row_status) do
|
||||
{:ok, results} when is_map(results) -> build_vlan_index_map(results)
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
# Build VLAN list from names
|
||||
vlans =
|
||||
name_results
|
||||
|> Enum.map(fn {oid, name} ->
|
||||
vlan_id = extract_vlan_id_from_oid(oid)
|
||||
status = Map.get(status_map, vlan_id, 1)
|
||||
|
||||
if valid_vlan_id?(vlan_id) do
|
||||
%{
|
||||
vlan_id: vlan_id,
|
||||
vlan_name: name,
|
||||
vlan_type: "ethernet",
|
||||
status: vlan_row_status_to_status(status),
|
||||
last_checked_at: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Logger.debug("Discovered #{length(vlans)} VLANs from Q-BRIDGE-MIB")
|
||||
{:ok, vlans}
|
||||
end
|
||||
|
||||
defp try_vlan_status_walk(client_opts) do
|
||||
# Try to discover VLANs from status table (some devices only have this)
|
||||
case Client.walk(client_opts, @q_bridge_oids.dot1q_vlan_static_row_status) do
|
||||
{:ok, status_results} when is_map(status_results) and map_size(status_results) > 0 ->
|
||||
vlans = build_vlans_from_status(status_results)
|
||||
Logger.debug("Discovered #{length(vlans)} VLANs from status walk")
|
||||
{:ok, vlans}
|
||||
|
||||
_ ->
|
||||
Logger.debug("Device does not support Q-BRIDGE-MIB")
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_vlans_from_status(status_results) do
|
||||
status_results
|
||||
|> Enum.map(&build_vlan_from_status/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp build_vlan_from_status({oid, status}) do
|
||||
vlan_id = extract_vlan_id_from_oid(oid)
|
||||
|
||||
if valid_vlan_id?(vlan_id) do
|
||||
%{
|
||||
vlan_id: vlan_id,
|
||||
vlan_name: "VLAN #{vlan_id}",
|
||||
vlan_type: "ethernet",
|
||||
status: vlan_row_status_to_status(status),
|
||||
last_checked_at: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Identifies device manufacturer and model from sysDescr and sysObjectID.
|
||||
Can be overridden by vendor-specific profiles.
|
||||
|
|
@ -585,6 +677,41 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
defp entity_state_to_descr(4), do: "testing"
|
||||
defp entity_state_to_descr(_), do: "unknown"
|
||||
|
||||
# VLAN helper functions
|
||||
|
||||
defp build_vlan_index_map(walk_map) when is_map(walk_map) do
|
||||
Map.new(walk_map, fn {oid, value} ->
|
||||
vlan_id = extract_vlan_id_from_oid(oid)
|
||||
{vlan_id, value}
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_vlan_index_map(_), do: %{}
|
||||
|
||||
defp extract_vlan_id_from_oid(oid) when is_binary(oid) do
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.to_integer()
|
||||
rescue
|
||||
_ -> 0
|
||||
end
|
||||
|
||||
defp extract_vlan_id_from_oid(_), do: 0
|
||||
|
||||
defp valid_vlan_id?(vlan_id) when is_integer(vlan_id) do
|
||||
vlan_id >= 1 and vlan_id <= 4094
|
||||
end
|
||||
|
||||
defp valid_vlan_id?(_), do: false
|
||||
|
||||
# Q-BRIDGE-MIB RowStatus values:
|
||||
# 1=active, 2=notInService, 3=notReady, 4=createAndGo, 5=createAndWait, 6=destroy
|
||||
defp vlan_row_status_to_status(1), do: "active"
|
||||
defp vlan_row_status_to_status(2), do: "suspended"
|
||||
defp vlan_row_status_to_status(3), do: "suspended"
|
||||
defp vlan_row_status_to_status(_), do: "active"
|
||||
|
||||
@doc """
|
||||
Collects raw debug data for troubleshooting.
|
||||
Includes system OIDs and interface/sensor tables.
|
||||
|
|
|
|||
73
lib/towerops/snmp/vlan.ex
Normal file
73
lib/towerops/snmp/vlan.ex
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
defmodule Towerops.Snmp.Vlan do
|
||||
@moduledoc """
|
||||
SNMP VLAN schema for tracking VLANs discovered on network devices.
|
||||
|
||||
VLANs (Virtual LANs) segment network traffic and are discovered via:
|
||||
- Q-BRIDGE-MIB (IEEE 802.1Q standard)
|
||||
- Cisco VTP MIB (Cisco-specific)
|
||||
|
||||
Status values:
|
||||
- active: VLAN is operational
|
||||
- suspended: VLAN is administratively suspended
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Ecto.Association.NotLoaded
|
||||
alias Towerops.Snmp.Device
|
||||
|
||||
@valid_statuses ~w(active suspended)
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "snmp_vlans" do
|
||||
field :vlan_id, :integer
|
||||
field :vlan_name, :string
|
||||
field :vlan_type, :string
|
||||
field :status, :string, default: "active"
|
||||
field :last_checked_at, :utc_datetime
|
||||
field :metadata, :map, default: %{}
|
||||
|
||||
belongs_to :snmp_device, Device
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: Ecto.UUID.t(),
|
||||
vlan_id: integer(),
|
||||
vlan_name: String.t() | nil,
|
||||
vlan_type: String.t() | nil,
|
||||
status: String.t(),
|
||||
last_checked_at: DateTime.t() | nil,
|
||||
metadata: map(),
|
||||
snmp_device_id: Ecto.UUID.t(),
|
||||
snmp_device: NotLoaded.t() | Device.t(),
|
||||
inserted_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
|
||||
@doc false
|
||||
def changeset(vlan, attrs) do
|
||||
vlan
|
||||
|> cast(attrs, [
|
||||
:snmp_device_id,
|
||||
:vlan_id,
|
||||
:vlan_name,
|
||||
:vlan_type,
|
||||
:status,
|
||||
:last_checked_at,
|
||||
:metadata
|
||||
])
|
||||
|> validate_required([:snmp_device_id, :vlan_id])
|
||||
|> validate_inclusion(:status, @valid_statuses)
|
||||
|> validate_number(:vlan_id,
|
||||
greater_than_or_equal_to: 1,
|
||||
less_than_or_equal_to: 4094,
|
||||
message: "must be between 1 and 4094"
|
||||
)
|
||||
|> unique_constraint([:snmp_device_id, :vlan_id])
|
||||
|> foreign_key_constraint(:snmp_device_id)
|
||||
end
|
||||
end
|
||||
25
priv/repo/migrations/20260121162517_create_snmp_vlans.exs
Normal file
25
priv/repo/migrations/20260121162517_create_snmp_vlans.exs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateSnmpVlans do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:snmp_vlans, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :vlan_id, :integer, null: false
|
||||
add :vlan_name, :string
|
||||
add :vlan_type, :string
|
||||
add :status, :string, default: "active"
|
||||
add :last_checked_at, :utc_datetime
|
||||
add :metadata, :map, default: %{}
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:snmp_vlans, [:snmp_device_id])
|
||||
create unique_index(:snmp_vlans, [:snmp_device_id, :vlan_id])
|
||||
create index(:snmp_vlans, [:status])
|
||||
end
|
||||
end
|
||||
|
|
@ -548,4 +548,143 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
|
|||
assert statuses["4"] == "unknown"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_vlans/1" do
|
||||
test "discovers VLANs from Q-BRIDGE-MIB" do
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
# dot1qVlanStaticName - VLAN names
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.1", value: {:octet_string, "default"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.10", value: {:octet_string, "Management"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.100", value: {:octet_string, "Production"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.200", value: {:octet_string, "Guest"}}
|
||||
]}
|
||||
|
||||
# dot1qVlanStaticRowStatus - VLAN status
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.1", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.10", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.100", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.200", value: {:integer, 2}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(@client_opts)
|
||||
|
||||
assert length(vlans) == 4
|
||||
|
||||
vlan1 = Enum.find(vlans, &(&1.vlan_id == 1))
|
||||
assert vlan1.vlan_name == "default"
|
||||
assert vlan1.status == "active"
|
||||
|
||||
vlan100 = Enum.find(vlans, &(&1.vlan_id == 100))
|
||||
assert vlan100.vlan_name == "Production"
|
||||
assert vlan100.status == "active"
|
||||
|
||||
vlan200 = Enum.find(vlans, &(&1.vlan_id == 200))
|
||||
assert vlan200.vlan_name == "Guest"
|
||||
assert vlan200.status == "suspended"
|
||||
end
|
||||
|
||||
test "returns empty list when Q-BRIDGE-MIB not supported" do
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(@client_opts)
|
||||
assert vlans == []
|
||||
end
|
||||
|
||||
test "handles walk errors gracefully" do
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(@client_opts)
|
||||
assert vlans == []
|
||||
end
|
||||
|
||||
test "handles VLANs with missing names" do
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
# dot1qVlanStaticName - Only some VLANs have names
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.1", value: {:octet_string, "default"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.10", value: {:octet_string, "Management"}}
|
||||
]}
|
||||
|
||||
# dot1qVlanStaticRowStatus - More VLANs exist than have names
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.1", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.10", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.50", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.100", value: {:integer, 1}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(@client_opts)
|
||||
|
||||
# Only 2 VLANs from the name walk (status-only VLANs require fallback)
|
||||
assert length(vlans) == 2
|
||||
|
||||
# Named VLANs
|
||||
vlan1 = Enum.find(vlans, &(&1.vlan_id == 1))
|
||||
assert vlan1.vlan_name == "default"
|
||||
|
||||
vlan10 = Enum.find(vlans, &(&1.vlan_id == 10))
|
||||
assert vlan10.vlan_name == "Management"
|
||||
end
|
||||
|
||||
test "validates VLAN ID range (1-4094)" do
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.0", value: {:octet_string, "invalid"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.1", value: {:octet_string, "default"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.4094", value: {:octet_string, "max"}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.4095", value: {:octet_string, "too_high"}}
|
||||
]}
|
||||
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.0", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.1", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.4094", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.4095", value: {:integer, 1}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(@client_opts)
|
||||
|
||||
vlan_ids = Enum.map(vlans, & &1.vlan_id)
|
||||
|
||||
# Should include valid VLANs
|
||||
assert 1 in vlan_ids
|
||||
assert 4094 in vlan_ids
|
||||
|
||||
# Should exclude invalid VLANs (0 and 4095+)
|
||||
refute 0 in vlan_ids
|
||||
refute 4095 in vlan_ids
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
220
test/towerops/snmp/vlan_test.exs
Normal file
220
test/towerops/snmp/vlan_test.exs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
defmodule Towerops.Snmp.VlanTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Vlan
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
{:ok, organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device_schema} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Switch",
|
||||
ip_address: "192.168.1.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id
|
||||
})
|
||||
|
||||
snmp_device =
|
||||
%Device{}
|
||||
|> Device.changeset(%{
|
||||
device_id: device_schema.id,
|
||||
sys_name: "test-switch",
|
||||
sys_descr: "Test Switch"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%{device: device_schema, snmp_device: snmp_device}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with all required fields", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: 100
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "valid changeset with all fields", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: 100,
|
||||
vlan_name: "Production",
|
||||
vlan_type: "ethernet",
|
||||
status: "active",
|
||||
last_checked_at: DateTime.utc_now(),
|
||||
metadata: %{"source" => "q-bridge"}
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :vlan_name) == "Production"
|
||||
assert get_field(changeset, :vlan_type) == "ethernet"
|
||||
assert get_field(changeset, :status) == "active"
|
||||
end
|
||||
|
||||
test "invalid changeset without snmp_device_id" do
|
||||
attrs = %{
|
||||
vlan_id: 100
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).snmp_device_id
|
||||
end
|
||||
|
||||
test "invalid changeset without vlan_id", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).vlan_id
|
||||
end
|
||||
|
||||
test "invalid status value", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: 100,
|
||||
status: "invalid_status"
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).status
|
||||
end
|
||||
|
||||
test "valid status values", %{snmp_device: snmp_device} do
|
||||
for status <- ~w(active suspended) do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: Enum.random(100..999),
|
||||
status: status
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
assert changeset.valid?, "status '#{status}' should be valid"
|
||||
end
|
||||
end
|
||||
|
||||
test "default status is active", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: 100
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "validates vlan_id range", %{snmp_device: snmp_device} do
|
||||
# VLAN IDs must be between 1 and 4094
|
||||
for invalid_id <- [0, -1, 4095, 5000] do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: invalid_id
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
refute changeset.valid?, "vlan_id #{invalid_id} should be invalid"
|
||||
assert "must be between 1 and 4094" in errors_on(changeset).vlan_id
|
||||
end
|
||||
end
|
||||
|
||||
test "accepts valid vlan_id range", %{snmp_device: snmp_device} do
|
||||
for valid_id <- [1, 100, 1000, 4094] do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: valid_id
|
||||
}
|
||||
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
assert changeset.valid?, "vlan_id #{valid_id} should be valid"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "unique constraint" do
|
||||
test "prevents duplicate VLANs with same device and vlan_id", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
vlan_id: 100,
|
||||
vlan_name: "Production"
|
||||
}
|
||||
|
||||
# Insert first VLAN
|
||||
changeset = Vlan.changeset(%Vlan{}, attrs)
|
||||
assert {:ok, _vlan} = Repo.insert(changeset)
|
||||
|
||||
# Attempt to insert duplicate
|
||||
changeset2 = Vlan.changeset(%Vlan{}, attrs)
|
||||
|
||||
assert {:error, changeset} = Repo.insert(changeset2)
|
||||
errors = errors_on(changeset)
|
||||
|
||||
assert "has already been taken" in Map.get(errors, :vlan_id, []) or
|
||||
"has already been taken" in Map.get(errors, :snmp_device_id, [])
|
||||
end
|
||||
|
||||
test "allows same vlan_id for different devices", %{device: device_schema} do
|
||||
# Create second device
|
||||
{:ok, device2} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Router",
|
||||
ip_address: "192.168.1.2",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: device_schema.site_id
|
||||
})
|
||||
|
||||
snmp_device2 =
|
||||
%Device{}
|
||||
|> Device.changeset(%{
|
||||
device_id: device2.id,
|
||||
sys_name: "test-router",
|
||||
sys_descr: "Test Router"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
snmp_device1 = Repo.get_by!(Device, device_id: device_schema.id)
|
||||
|
||||
attrs1 = %{
|
||||
snmp_device_id: snmp_device1.id,
|
||||
vlan_id: 100,
|
||||
vlan_name: "Production"
|
||||
}
|
||||
|
||||
attrs2 = %{
|
||||
snmp_device_id: snmp_device2.id,
|
||||
vlan_id: 100,
|
||||
vlan_name: "Production"
|
||||
}
|
||||
|
||||
changeset1 = Vlan.changeset(%Vlan{}, attrs1)
|
||||
assert {:ok, _vlan1} = Repo.insert(changeset1)
|
||||
|
||||
changeset2 = Vlan.changeset(%Vlan{}, attrs2)
|
||||
assert {:ok, _vlan2} = Repo.insert(changeset2)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue