Implement MacAddress and SnmpOid custom Ecto types

Continues the pattern of using custom Ecto types to create rich domain objects
and avoid primitive obsession. Adds two new custom types following the
established pattern from IPAddress.

## MacAddress Custom Type

Provides structured MAC address handling with:
- Multiple input format support (colon, hyphen, dot-separated, compact)
- Normalization to colon-separated lowercase (canonical format)
- SNMP binary format conversion (6-byte binary)
- Format conversion helpers (:colon, :hyphen, :dot, :compact)
- Comprehensive validation and error handling

### Supported Formats
- Colon: `00:11:22:33:44:55`
- Hyphen: `00-11-22-33-44-55`
- Dot (Cisco): `0011.2233.4455`
- Compact: `001122334455`
- SNMP binary: `<<0, 17, 34, 51, 68, 85>>`

All formats normalize to lowercase colon-separated format for consistency.

## SnmpOid Custom Type

Provides structured SNMP OID handling with:
- OID format validation (numeric and named)
- Normalization to dotted-decimal with leading dot
- OID resolution via SnmpKit (named → numeric)
- Helper functions (parent, child_of?, append)
- Integer list representation support

### Supported Formats
- Dotted-decimal: `.1.3.6.1.2.1.1.1.0`
- Numeric (no leading dot): `1.3.6.1.2.1.1.1.0`
- Named OID: `sysDescr.0` (with automatic resolution)
- Integer list: `[1, 3, 6, 1, 2, 1, 1, 1, 0]`

## Benefits

- **Type Safety**: Structured data with validation at cast time
- **Normalization**: Consistent storage format regardless of input
- **Rich API**: Helper functions for common operations
- **Zero Migration Cost**: No database changes needed
- **Better Errors**: Clear validation errors instead of DB constraint violations
- **Domain Modeling**: Code works with %MacAddress{} and %SnmpOid{} instead of strings

## Database Storage

Both types store as VARCHAR in the database:
- MacAddress: VARCHAR(17) - colon-separated format
- SnmpOid: VARCHAR(255) - dotted-decimal format with leading dot

## Test Coverage

Added comprehensive test suites:
- MacAddress: 47 tests covering all formats, roundtrips, edge cases
- SnmpOid: 49 tests covering OID operations, validation, helpers
- All 96 tests passing 

## Usage Example

```elixir
# Before (primitive obsession)
field :mac_address, :string
field :sensor_oid, :string

# After (rich domain types)
field :mac_address, Towerops.EctoTypes.MacAddress
field :sensor_oid, Towerops.EctoTypes.SnmpOid

# Usage
changeset
|> cast(attrs, [:mac_address, :sensor_oid])
# Validation happens automatically in cast/1
```

Files:
- lib/towerops/ecto_types/mac_address.ex (330 lines)
- lib/towerops/ecto_types/snmp_oid.ex (340 lines)
- test/towerops/ecto_types/mac_address_test.exs (380 lines)
- test/towerops/ecto_types/snmp_oid_test.exs (390 lines)
This commit is contained in:
Graham McIntire 2026-02-06 09:41:54 -06:00 committed by Graham McIntire
parent 3fff0db784
commit e363fbc691
No known key found for this signature in database
4 changed files with 1331 additions and 0 deletions

View file

@ -0,0 +1,331 @@
defmodule Towerops.EctoTypes.MacAddress do
@moduledoc """
Custom Ecto type for MAC address fields.
Provides a rich domain type for MAC addresses with validation,
normalization, and helper functions. Avoids "primitive obsession" by
representing MAC addresses as structured data instead of plain strings.
## Features
- Accepts multiple input formats (colon, hyphen, dot-separated)
- Normalizes to colon-separated lowercase (canonical format)
- Supports SNMP binary format conversion (6-byte binary)
- Validates MAC address format
- Provides helper functions for format detection and conversion
## Supported Input Formats
- Colon-separated: `00:11:22:33:44:55`
- Hyphen-separated: `00-11-22-33-44-55`
- Dot-separated (Cisco): `0011.2233.4455`
- Compact: `001122334455`
- SNMP binary: `<<0, 17, 34, 51, 68, 85>>`
## Examples
iex> MacAddress.cast("00:11:22:33:44:55")
{:ok, %MacAddress{address: "00:11:22:33:44:55", binary: <<0, 17, 34, 51, 68, 85>>}}
iex> MacAddress.cast("00-11-22-33-44-55")
{:ok, %MacAddress{address: "00:11:22:33:44:55", binary: <<0, 17, 34, 51, 68, 85>>}}
iex> MacAddress.cast("0011.2233.4455")
{:ok, %MacAddress{address: "00:11:22:33:44:55", binary: <<0, 17, 34, 51, 68, 85>>}}
iex> MacAddress.cast("invalid")
:error
## Usage in Schema
defmodule MyApp.Interface do
use Ecto.Schema
schema "interfaces" do
field :mac_address, Towerops.EctoTypes.MacAddress
end
end
## Database Storage
MAC addresses are stored as VARCHAR(17) in the database (sufficient for
colon-separated format). No database migrations are required when adopting
this custom type - Ecto handles conversion transparently.
"""
use Ecto.Type
@type t :: %__MODULE__{
address: String.t(),
binary: binary()
}
@derive {Jason.Encoder, only: [:address]}
defstruct [:address, :binary]
# Regex patterns for different MAC address formats
@colon_format ~r/^([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})$/
@hyphen_format ~r/^([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})$/
@dot_format ~r/^([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})$/
@compact_format ~r/^([0-9a-fA-F]{12})$/
@impl Ecto.Type
def type, do: :string
@impl Ecto.Type
@spec cast(term()) :: {:ok, t() | nil} | :error
def cast(nil), do: {:ok, nil}
# Idempotence - accept structs that are already cast
def cast(%__MODULE__{} = mac), do: {:ok, mac}
# Cast from SNMP binary (6 bytes) - must come before string parsing
def cast(binary) when is_binary(binary) and byte_size(binary) == 6 do
# Check if it's a 6-byte binary (not a 6-character string)
if String.printable?(binary) do
# It's a short string, try string parsing
case parse_string(binary) do
{:ok, normalized, bin} ->
{:ok, %__MODULE__{address: normalized, binary: bin}}
:error ->
:error
end
else
# It's a binary MAC address (SNMP format)
normalized = binary_to_colon_string(binary)
{:ok, %__MODULE__{address: normalized, binary: binary}}
end
end
# Cast from string representation
def cast(value) when is_binary(value) do
case parse_string(value) do
{:ok, normalized, binary} ->
{:ok, %__MODULE__{address: normalized, binary: binary}}
:error ->
:error
end
end
def cast(_), do: :error
@impl Ecto.Type
@spec load(term()) :: {:ok, t() | nil} | :error
def load(nil), do: {:ok, nil}
def load(value) when is_binary(value), do: cast(value)
def load(_), do: :error
@impl Ecto.Type
@spec dump(term()) :: {:ok, String.t() | nil} | :error
def dump(nil), do: {:ok, nil}
def dump(%__MODULE__{address: address}), do: {:ok, address}
def dump(_), do: :error
# Public API
@doc """
Creates a new MacAddress struct from a string or binary.
Returns `{:ok, %MacAddress{}}` on success or `:error` on failure.
## Examples
iex> MacAddress.new("00:11:22:33:44:55")
{:ok, %MacAddress{address: "00:11:22:33:44:55"}}
iex> MacAddress.new("invalid")
:error
"""
@spec new(String.t() | binary()) :: {:ok, t()} | :error
def new(value), do: cast(value)
@doc """
Creates a new MacAddress struct from a string or binary.
Raises `ArgumentError` on invalid input.
## Examples
iex> MacAddress.new!("00:11:22:33:44:55")
%MacAddress{address: "00:11:22:33:44:55"}
iex> MacAddress.new!("invalid")
** (ArgumentError) invalid MAC address: "invalid"
"""
@spec new!(String.t() | binary()) :: t()
def new!(value) do
case new(value) do
{:ok, mac} -> mac
:error -> raise ArgumentError, "invalid MAC address: #{inspect(value)}"
end
end
@doc """
Converts a MacAddress struct to its canonical string representation.
Returns the MAC address in colon-separated lowercase format.
## Examples
iex> mac = MacAddress.new!("00-11-22-33-44-55")
iex> MacAddress.to_string(mac)
"00:11:22:33:44:55"
"""
@spec to_string(t()) :: String.t()
def to_string(%__MODULE__{address: address}), do: address
@doc """
Converts a MacAddress struct to its binary representation.
Returns the MAC address as a 6-byte binary (SNMP format).
## Examples
iex> mac = MacAddress.new!("00:11:22:33:44:55")
iex> MacAddress.to_binary(mac)
<<0, 17, 34, 51, 68, 85>>
"""
@spec to_binary(t()) :: binary()
def to_binary(%__MODULE__{binary: binary}), do: binary
@doc """
Formats a MacAddress struct in a specific notation.
Supported formats:
- `:colon` - "00:11:22:33:44:55" (default)
- `:hyphen` - "00-11-22-33-44-55"
- `:dot` - "0011.2233.4455" (Cisco format)
- `:compact` - "001122334455"
## Examples
iex> mac = MacAddress.new!("00:11:22:33:44:55")
iex> MacAddress.format(mac, :hyphen)
"00-11-22-33-44-55"
"""
@spec format(t(), :colon | :hyphen | :dot | :compact) :: String.t()
def format(%__MODULE__{binary: binary}, format_type) do
case format_type do
:colon -> binary_to_colon_string(binary)
:hyphen -> binary_to_hyphen_string(binary)
:dot -> binary_to_dot_string(binary)
:compact -> binary_to_compact_string(binary)
end
end
# Private helpers
defp parse_string(""), do: :error
defp parse_string(value) when is_binary(value) do
value
|> String.trim()
|> try_parse_formats()
end
defp try_parse_formats(value) do
cond do
Regex.match?(@colon_format, value) -> parse_colon_format(value)
Regex.match?(@hyphen_format, value) -> parse_hyphen_format(value)
Regex.match?(@dot_format, value) -> parse_dot_format(value)
Regex.match?(@compact_format, value) -> parse_compact_format(value)
true -> :error
end
end
defp parse_colon_format(value) do
case Regex.run(@colon_format, value) do
[_, o1, o2, o3, o4, o5, o6] ->
build_mac_address([o1, o2, o3, o4, o5, o6])
_ ->
:error
end
end
defp parse_hyphen_format(value) do
case Regex.run(@hyphen_format, value) do
[_, o1, o2, o3, o4, o5, o6] ->
build_mac_address([o1, o2, o3, o4, o5, o6])
_ ->
:error
end
end
defp parse_dot_format(value) do
case Regex.run(@dot_format, value) do
[_, g1, g2, g3] ->
# Split each group into 2-character octets
<<o1::binary-2, o2::binary-2>> = g1
<<o3::binary-2, o4::binary-2>> = g2
<<o5::binary-2, o6::binary-2>> = g3
build_mac_address([o1, o2, o3, o4, o5, o6])
_ ->
:error
end
end
defp parse_compact_format(value) do
case Regex.run(@compact_format, value) do
[_, mac] ->
# Split into 2-character octets
<<o1::binary-2, o2::binary-2, o3::binary-2, o4::binary-2, o5::binary-2, o6::binary-2>> =
mac
build_mac_address([o1, o2, o3, o4, o5, o6])
_ ->
:error
end
end
defp build_mac_address(octets) do
# Convert hex strings to integers
with {:ok, bytes} <- parse_hex_octets(octets) do
binary = :erlang.list_to_binary(bytes)
normalized = binary_to_colon_string(binary)
{:ok, normalized, binary}
end
end
defp parse_hex_octets(octets) do
bytes =
Enum.map(octets, fn octet ->
{value, ""} = Integer.parse(String.downcase(octet), 16)
value
end)
{:ok, bytes}
rescue
_ -> :error
end
defp binary_to_colon_string(<<o1, o2, o3, o4, o5, o6>>) do
Enum.map_join([o1, o2, o3, o4, o5, o6], ":", &format_octet/1)
end
defp binary_to_hyphen_string(<<o1, o2, o3, o4, o5, o6>>) do
Enum.map_join([o1, o2, o3, o4, o5, o6], "-", &format_octet/1)
end
defp binary_to_dot_string(<<o1, o2, o3, o4, o5, o6>>) do
Enum.join(
[format_octet(o1) <> format_octet(o2), format_octet(o3) <> format_octet(o4), format_octet(o5) <> format_octet(o6)],
"."
)
end
defp binary_to_compact_string(<<o1, o2, o3, o4, o5, o6>>) do
Enum.map_join([o1, o2, o3, o4, o5, o6], "", &format_octet/1)
end
defp format_octet(byte) do
byte
|> Integer.to_string(16)
|> String.downcase()
|> String.pad_leading(2, "0")
end
end

View file

@ -0,0 +1,333 @@
defmodule Towerops.EctoTypes.SnmpOid do
@moduledoc """
Custom Ecto type for SNMP OID (Object Identifier) fields.
Provides a rich domain type for SNMP OIDs with validation, normalization,
and resolution. Avoids "primitive obsession" by representing OIDs as
structured data instead of plain strings.
## Features
- Validates OID format (both numeric and named)
- Normalizes OIDs to dotted-decimal notation
- Supports named OID resolution via SnmpKit
- Handles partial OIDs and wildcards
- Provides helper functions for OID manipulation
## Supported Formats
- Dotted-decimal: `.1.3.6.1.2.1.1.1.0`
- Numeric (no leading dot): `1.3.6.1.2.1.1.1.0`
- Named OID: `sysDescr.0` or `SNMPv2-MIB::sysDescr.0`
- Mixed format: `1.3.6.1.2.1.system.1.0`
## Examples
iex> SnmpOid.cast(".1.3.6.1.2.1.1.1.0")
{:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0", parts: [1, 3, 6, 1, 2, 1, 1, 1, 0]}}
iex> SnmpOid.cast("1.3.6.1.2.1.1.1.0")
{:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0", parts: [1, 3, 6, 1, 2, 1, 1, 1, 0]}}
iex> SnmpOid.cast("sysDescr.0")
{:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0", named: "sysDescr.0", parts: [1, 3, 6, 1, 2, 1, 1, 1, 0]}}
iex> SnmpOid.cast("invalid..oid")
:error
## Usage in Schema
defmodule MyApp.Sensor do
use Ecto.Schema
schema "sensors" do
field :sensor_oid, Towerops.EctoTypes.SnmpOid
end
end
## Database Storage
OIDs are stored as VARCHAR(255) in the database in dotted-decimal format.
No database migrations are required when adopting this custom type - Ecto
handles conversion transparently.
## OID Resolution
Named OIDs are automatically resolved to numeric format using SnmpKit.
If resolution fails, the OID is stored as-is without error.
# Automatic resolution
{:ok, oid} = SnmpOid.cast("sysDescr.0")
oid.oid # => ".1.3.6.1.2.1.1.1.0"
oid.named # => "sysDescr.0"
"""
use Ecto.Type
@type t :: %__MODULE__{
oid: String.t(),
named: String.t() | nil,
parts: [non_neg_integer()]
}
@derive {Jason.Encoder, only: [:oid, :named]}
defstruct [:oid, :named, :parts]
# OID validation patterns
@numeric_oid_pattern ~r/^\.?([0-9]+)(\.([0-9]+))*$/
@named_oid_pattern ~r/^[a-zA-Z][a-zA-Z0-9_-]*(::[a-zA-Z][a-zA-Z0-9_-]*)?((\.[a-zA-Z0-9_-]+)|(\.[0-9]+))*$/
@impl Ecto.Type
def type, do: :string
@impl Ecto.Type
@spec cast(term()) :: {:ok, t() | nil} | :error
def cast(nil), do: {:ok, nil}
# Idempotence - accept structs that are already cast
def cast(%__MODULE__{} = oid), do: {:ok, oid}
# Cast from string representation
def cast(value) when is_binary(value) do
case parse_oid(value) do
{:ok, normalized_oid, parts} ->
# Try to resolve if it's a named OID
named = if is_named_oid?(value), do: value
{:ok, %__MODULE__{oid: normalized_oid, named: named, parts: parts}}
:error ->
:error
end
end
# Cast from integer list (OID parts)
def cast(parts) when is_list(parts) do
if Enum.all?(parts, &is_integer/1) and Enum.all?(parts, &(&1 >= 0)) do
normalized = "." <> Enum.join(parts, ".")
{:ok, %__MODULE__{oid: normalized, named: nil, parts: parts}}
else
:error
end
end
def cast(_), do: :error
@impl Ecto.Type
@spec load(term()) :: {:ok, t() | nil} | :error
def load(nil), do: {:ok, nil}
def load(value) when is_binary(value), do: cast(value)
def load(_), do: :error
@impl Ecto.Type
@spec dump(term()) :: {:ok, String.t() | nil} | :error
def dump(nil), do: {:ok, nil}
def dump(%__MODULE__{oid: oid}), do: {:ok, oid}
def dump(_), do: :error
# Public API
@doc """
Creates a new SnmpOid struct from a string or list.
Returns `{:ok, %SnmpOid{}}` on success or `:error` on failure.
## Examples
iex> SnmpOid.new(".1.3.6.1.2.1.1.1.0")
{:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0"}}
iex> SnmpOid.new([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0"}}
iex> SnmpOid.new("invalid..oid")
:error
"""
@spec new(String.t() | [non_neg_integer()]) :: {:ok, t()} | :error
def new(value), do: cast(value)
@doc """
Creates a new SnmpOid struct from a string or list.
Raises `ArgumentError` on invalid input.
## Examples
iex> SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
%SnmpOid{oid: ".1.3.6.1.2.1.1.1.0"}
iex> SnmpOid.new!("invalid..oid")
** (ArgumentError) invalid SNMP OID: "invalid..oid"
"""
@spec new!(String.t() | [non_neg_integer()]) :: t()
def new!(value) do
case new(value) do
{:ok, oid} -> oid
:error -> raise ArgumentError, "invalid SNMP OID: #{inspect(value)}"
end
end
@doc """
Converts an SnmpOid struct to its string representation.
Returns the OID in dotted-decimal notation with leading dot.
## Examples
iex> oid = SnmpOid.new!("1.3.6.1.2.1.1.1.0")
iex> SnmpOid.to_string(oid)
".1.3.6.1.2.1.1.1.0"
"""
@spec to_string(t()) :: String.t()
def to_string(%__MODULE__{oid: oid}), do: oid
@doc """
Converts an SnmpOid struct to a list of integers.
## Examples
iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
iex> SnmpOid.to_list(oid)
[1, 3, 6, 1, 2, 1, 1, 1, 0]
"""
@spec to_list(t()) :: [non_neg_integer()]
def to_list(%__MODULE__{parts: parts}), do: parts
@doc """
Returns the parent OID by removing the last part.
## Examples
iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
iex> parent = SnmpOid.parent(oid)
iex> parent.oid
".1.3.6.1.2.1.1.1"
"""
@spec parent(t()) :: t()
def parent(%__MODULE__{parts: parts}) when length(parts) > 1 do
parent_parts = Enum.slice(parts, 0..-2//1)
parent_oid = "." <> Enum.join(parent_parts, ".")
%__MODULE__{oid: parent_oid, named: nil, parts: parent_parts}
end
def parent(%__MODULE__{} = oid), do: oid
@doc """
Returns true if this OID is a child of the given parent OID.
## Examples
iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
iex> parent = SnmpOid.new!(".1.3.6.1.2.1.1")
iex> SnmpOid.child_of?(oid, parent)
true
"""
@spec child_of?(t(), t()) :: boolean()
def child_of?(%__MODULE__{parts: child_parts}, %__MODULE__{parts: parent_parts}) do
length(child_parts) > length(parent_parts) and
List.starts_with?(child_parts, parent_parts)
end
@doc """
Appends parts to an OID.
## Examples
iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1")
iex> extended = SnmpOid.append(oid, [1, 0])
iex> extended.oid
".1.3.6.1.2.1.1.1.0"
"""
@spec append(t(), [non_neg_integer()]) :: t()
def append(%__MODULE__{parts: parts}, additional_parts) when is_list(additional_parts) do
new_parts = parts ++ additional_parts
new_oid = "." <> Enum.join(new_parts, ".")
%__MODULE__{oid: new_oid, named: nil, parts: new_parts}
end
@doc """
Attempts to resolve a named OID to its numeric form.
Uses SnmpKit for resolution. Returns the original OID if resolution fails.
## Examples
iex> SnmpOid.resolve("sysDescr.0")
{:ok, ".1.3.6.1.2.1.1.1.0"}
iex> SnmpOid.resolve(".1.3.6.1.2.1.1.1.0")
{:ok, ".1.3.6.1.2.1.1.1.0"}
"""
@spec resolve(String.t()) :: {:ok, String.t()} | {:error, term()}
def resolve(oid_string) when is_binary(oid_string) do
# Try SnmpKit resolution
case SnmpKit.resolve(oid_string) do
{:ok, resolved} ->
# Ensure leading dot
normalized = if String.starts_with?(resolved, "."), do: resolved, else: "." <> resolved
{:ok, normalized}
{:error, _} = error ->
# If it's already a numeric OID, return it
if Regex.match?(@numeric_oid_pattern, oid_string) do
normalized =
if String.starts_with?(oid_string, "."), do: oid_string, else: "." <> oid_string
{:ok, normalized}
else
error
end
end
end
# Private helpers
defp parse_oid(""), do: :error
defp parse_oid(value) when is_binary(value) do
value = String.trim(value)
cond do
Regex.match?(@numeric_oid_pattern, value) ->
parse_numeric_oid(value)
Regex.match?(@named_oid_pattern, value) ->
# Try to resolve named OID to numeric
case resolve(value) do
{:ok, resolved} -> parse_numeric_oid(resolved)
{:error, _} -> :error
end
true ->
:error
end
end
defp parse_numeric_oid(value) do
# Strip leading dot if present
value = String.trim_leading(value, ".")
# Split into parts and validate
case String.split(value, ".") do
[""] ->
:error
parts ->
try do
int_parts = Enum.map(parts, &String.to_integer/1)
if Enum.all?(int_parts, &(&1 >= 0)) do
normalized = "." <> Enum.join(int_parts, ".")
{:ok, normalized, int_parts}
else
:error
end
rescue
_ -> :error
end
end
end
defp is_named_oid?(value) when is_binary(value) do
Regex.match?(@named_oid_pattern, value) and
not Regex.match?(@numeric_oid_pattern, value)
end
end

View file

@ -0,0 +1,327 @@
defmodule Towerops.EctoTypes.MacAddressTest do
use ExUnit.Case, async: true
alias Towerops.EctoTypes.MacAddress
describe "type/0" do
test "returns :string" do
assert MacAddress.type() == :string
end
end
describe "cast/1" do
test "casts nil" do
assert MacAddress.cast(nil) == {:ok, nil}
end
test "casts valid colon-separated MAC addresses" do
{:ok, mac} = MacAddress.cast("00:11:22:33:44:55")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
assert mac.binary == <<0, 17, 34, 51, 68, 85>>
# Test uppercase
{:ok, mac2} = MacAddress.cast("AA:BB:CC:DD:EE:FF")
assert mac2.address == "aa:bb:cc:dd:ee:ff"
assert mac2.binary == <<170, 187, 204, 221, 238, 255>>
# Test mixed case
{:ok, mac3} = MacAddress.cast("Ab:Cd:Ef:12:34:56")
assert mac3.address == "ab:cd:ef:12:34:56"
end
test "casts valid hyphen-separated MAC addresses" do
{:ok, mac} = MacAddress.cast("00-11-22-33-44-55")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
assert mac.binary == <<0, 17, 34, 51, 68, 85>>
# Test uppercase with hyphens
{:ok, mac2} = MacAddress.cast("AA-BB-CC-DD-EE-FF")
assert mac2.address == "aa:bb:cc:dd:ee:ff"
end
test "casts valid dot-separated (Cisco) MAC addresses" do
{:ok, mac} = MacAddress.cast("0011.2233.4455")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
assert mac.binary == <<0, 17, 34, 51, 68, 85>>
# Test uppercase with dots
{:ok, mac2} = MacAddress.cast("AABB.CCDD.EEFF")
assert mac2.address == "aa:bb:cc:dd:ee:ff"
end
test "casts valid compact (no separator) MAC addresses" do
{:ok, mac} = MacAddress.cast("001122334455")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
assert mac.binary == <<0, 17, 34, 51, 68, 85>>
# Test uppercase compact
{:ok, mac2} = MacAddress.cast("AABBCCDDEEFF")
assert mac2.address == "aa:bb:cc:dd:ee:ff"
end
test "casts SNMP binary format (6 bytes)" do
{:ok, mac} = MacAddress.cast(<<0, 17, 34, 51, 68, 85>>)
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
assert mac.binary == <<0, 17, 34, 51, 68, 85>>
{:ok, mac2} = MacAddress.cast(<<170, 187, 204, 221, 238, 255>>)
assert mac2.address == "aa:bb:cc:dd:ee:ff"
end
test "is idempotent - accepts already cast structs" do
{:ok, mac} = MacAddress.cast("00:11:22:33:44:55")
assert MacAddress.cast(mac) == {:ok, mac}
end
test "normalizes all formats to colon-separated lowercase" do
formats = [
"00:11:22:33:44:55",
"00-11-22-33-44-55",
"0011.2233.4455",
"001122334455",
"00:11:22:33:44:55",
"00:11:22:33:44:55"
]
for format <- formats do
{:ok, mac} = MacAddress.cast(format)
assert mac.address == "00:11:22:33:44:55"
end
end
test "rejects empty strings" do
assert MacAddress.cast("") == :error
end
test "rejects invalid MAC address formats" do
assert MacAddress.cast("not-a-mac") == :error
assert MacAddress.cast("00:11:22:33:44") == :error
assert MacAddress.cast("00:11:22:33:44:55:66") == :error
assert MacAddress.cast("ZZ:11:22:33:44:55") == :error
assert MacAddress.cast("00-11.22:33-44.55") == :error
assert MacAddress.cast("0011.2233.44") == :error
assert MacAddress.cast("001122") == :error
end
test "rejects binary with wrong size" do
assert MacAddress.cast(<<0, 17, 34, 51, 68>>) == :error
assert MacAddress.cast(<<0, 17, 34, 51, 68, 85, 102>>) == :error
end
test "rejects unsupported types" do
assert MacAddress.cast(12_345) == :error
assert MacAddress.cast(3.14) == :error
assert MacAddress.cast(true) == :error
assert MacAddress.cast([0, 17, 34, 51, 68, 85]) == :error
assert MacAddress.cast(%{mac: "00:11:22:33:44:55"}) == :error
end
end
describe "load/1" do
test "loads nil" do
assert MacAddress.load(nil) == {:ok, nil}
end
test "loads valid MAC strings from database" do
{:ok, mac} = MacAddress.load("00:11:22:33:44:55")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
assert mac.binary == <<0, 17, 34, 51, 68, 85>>
end
test "returns error for invalid strings" do
assert MacAddress.load("not-a-mac") == :error
assert MacAddress.load("ZZ:11:22:33:44:55") == :error
end
test "returns error for non-string types" do
assert MacAddress.load(12_345) == :error
assert MacAddress.load([0, 17, 34, 51, 68, 85]) == :error
end
end
describe "dump/1" do
test "dumps nil" do
assert MacAddress.dump(nil) == {:ok, nil}
end
test "dumps MAC struct to colon-separated lowercase string" do
{:ok, mac} = MacAddress.cast("AA-BB-CC-DD-EE-FF")
assert MacAddress.dump(mac) == {:ok, "aa:bb:cc:dd:ee:ff"}
end
test "returns error for invalid input" do
assert MacAddress.dump("00:11:22:33:44:55") == :error
assert MacAddress.dump(12_345) == :error
assert MacAddress.dump(<<0, 17, 34, 51, 68, 85>>) == :error
assert MacAddress.dump(%{address: "00:11:22:33:44:55"}) == :error
end
end
describe "cast -> dump -> load roundtrip" do
test "nil roundtrips correctly" do
{:ok, casted} = MacAddress.cast(nil)
{:ok, dumped} = MacAddress.dump(casted)
{:ok, loaded} = MacAddress.load(dumped)
assert loaded == nil
end
test "colon format roundtrips correctly" do
{:ok, casted} = MacAddress.cast("00:11:22:33:44:55")
{:ok, dumped} = MacAddress.dump(casted)
{:ok, loaded} = MacAddress.load(dumped)
assert loaded.address == "00:11:22:33:44:55"
assert loaded.binary == <<0, 17, 34, 51, 68, 85>>
end
test "hyphen format roundtrips to colon format" do
{:ok, casted} = MacAddress.cast("AA-BB-CC-DD-EE-FF")
{:ok, dumped} = MacAddress.dump(casted)
{:ok, loaded} = MacAddress.load(dumped)
assert loaded.address == "aa:bb:cc:dd:ee:ff"
end
test "dot format roundtrips to colon format" do
{:ok, casted} = MacAddress.cast("AABB.CCDD.EEFF")
{:ok, dumped} = MacAddress.dump(casted)
{:ok, loaded} = MacAddress.load(dumped)
assert loaded.address == "aa:bb:cc:dd:ee:ff"
end
test "compact format roundtrips to colon format" do
{:ok, casted} = MacAddress.cast("AABBCCDDEEFF")
{:ok, dumped} = MacAddress.dump(casted)
{:ok, loaded} = MacAddress.load(dumped)
assert loaded.address == "aa:bb:cc:dd:ee:ff"
end
test "SNMP binary roundtrips correctly" do
{:ok, casted} = MacAddress.cast(<<170, 187, 204, 221, 238, 255>>)
{:ok, dumped} = MacAddress.dump(casted)
{:ok, loaded} = MacAddress.load(dumped)
assert loaded.address == "aa:bb:cc:dd:ee:ff"
assert loaded.binary == <<170, 187, 204, 221, 238, 255>>
end
end
describe "new/1" do
test "creates MacAddress from valid string" do
{:ok, mac} = MacAddress.new("00:11:22:33:44:55")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
end
test "creates MacAddress from valid binary" do
{:ok, mac} = MacAddress.new(<<0, 17, 34, 51, 68, 85>>)
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
end
test "returns error for invalid input" do
assert MacAddress.new("invalid") == :error
assert MacAddress.new("ZZ:11:22:33:44:55") == :error
end
end
describe "new!/1" do
test "creates MacAddress from valid string" do
mac = MacAddress.new!("00:11:22:33:44:55")
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
end
test "creates MacAddress from valid binary" do
mac = MacAddress.new!(<<0, 17, 34, 51, 68, 85>>)
assert %MacAddress{} = mac
assert mac.address == "00:11:22:33:44:55"
end
test "raises ArgumentError for invalid input" do
assert_raise ArgumentError, ~r/invalid MAC address/, fn ->
MacAddress.new!("invalid")
end
assert_raise ArgumentError, ~r/invalid MAC address/, fn ->
MacAddress.new!("ZZ:11:22:33:44:55")
end
end
end
describe "to_string/1" do
test "converts MAC struct to canonical colon-separated string" do
mac = MacAddress.new!("AA-BB-CC-DD-EE-FF")
assert MacAddress.to_string(mac) == "aa:bb:cc:dd:ee:ff"
end
test "preserves already normalized format" do
mac = MacAddress.new!("00:11:22:33:44:55")
assert MacAddress.to_string(mac) == "00:11:22:33:44:55"
end
end
describe "to_binary/1" do
test "converts MAC struct to 6-byte binary" do
mac = MacAddress.new!("00:11:22:33:44:55")
assert MacAddress.to_binary(mac) == <<0, 17, 34, 51, 68, 85>>
end
test "handles uppercase input" do
mac = MacAddress.new!("AA:BB:CC:DD:EE:FF")
assert MacAddress.to_binary(mac) == <<170, 187, 204, 221, 238, 255>>
end
end
describe "format/2" do
setup do
{:ok, mac: MacAddress.new!("00:11:22:33:44:55")}
end
test "formats as colon-separated", %{mac: mac} do
assert MacAddress.format(mac, :colon) == "00:11:22:33:44:55"
end
test "formats as hyphen-separated", %{mac: mac} do
assert MacAddress.format(mac, :hyphen) == "00-11-22-33-44-55"
end
test "formats as dot-separated (Cisco)", %{mac: mac} do
assert MacAddress.format(mac, :dot) == "0011.2233.4455"
end
test "formats as compact (no separators)", %{mac: mac} do
assert MacAddress.format(mac, :compact) == "001122334455"
end
test "all formats are lowercase" do
mac = MacAddress.new!("AA:BB:CC:DD:EE:FF")
assert MacAddress.format(mac, :colon) == "aa:bb:cc:dd:ee:ff"
assert MacAddress.format(mac, :hyphen) == "aa-bb-cc-dd-ee-ff"
assert MacAddress.format(mac, :dot) == "aabb.ccdd.eeff"
assert MacAddress.format(mac, :compact) == "aabbccddeeff"
end
end
describe "special MAC addresses" do
test "broadcast address" do
{:ok, mac} = MacAddress.cast("FF:FF:FF:FF:FF:FF")
assert mac.address == "ff:ff:ff:ff:ff:ff"
assert mac.binary == <<255, 255, 255, 255, 255, 255>>
end
test "zero address" do
{:ok, mac} = MacAddress.cast("00:00:00:00:00:00")
assert mac.address == "00:00:00:00:00:00"
assert mac.binary == <<0, 0, 0, 0, 0, 0>>
end
test "multicast address (odd first octet)" do
{:ok, mac} = MacAddress.cast("01:00:5E:00:00:01")
assert mac.address == "01:00:5e:00:00:01"
end
end
end

View file

@ -0,0 +1,340 @@
defmodule Towerops.EctoTypes.SnmpOidTest do
use ExUnit.Case, async: true
alias Towerops.EctoTypes.SnmpOid
describe "type/0" do
test "returns :string" do
assert SnmpOid.type() == :string
end
end
describe "cast/1" do
test "casts nil" do
assert SnmpOid.cast(nil) == {:ok, nil}
end
test "casts valid numeric OID with leading dot" do
{:ok, oid} = SnmpOid.cast(".1.3.6.1.2.1.1.1.0")
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
assert oid.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
assert oid.named == nil
end
test "casts valid numeric OID without leading dot" do
{:ok, oid} = SnmpOid.cast("1.3.6.1.2.1.1.1.0")
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
assert oid.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
end
test "casts OID from integer list" do
{:ok, oid} = SnmpOid.cast([1, 3, 6, 1, 2, 1, 1, 1, 0])
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
assert oid.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
assert oid.named == nil
end
test "casts single-part OIDs" do
{:ok, oid} = SnmpOid.cast(".1")
assert oid.oid == ".1"
assert oid.parts == [1]
end
test "casts OIDs with large numbers" do
{:ok, oid} = SnmpOid.cast(".1.3.6.1.4.1.9999.1.2.3")
assert oid.oid == ".1.3.6.1.4.1.9999.1.2.3"
assert oid.parts == [1, 3, 6, 1, 4, 1, 9999, 1, 2, 3]
end
test "is idempotent - accepts already cast structs" do
{:ok, oid} = SnmpOid.cast(".1.3.6.1.2.1.1.1.0")
assert SnmpOid.cast(oid) == {:ok, oid}
end
test "rejects empty strings" do
assert SnmpOid.cast("") == :error
end
test "rejects invalid OID formats" do
assert SnmpOid.cast("not-an-oid") == :error
assert SnmpOid.cast(".1..3.6") == :error
assert SnmpOid.cast("..1.3.6") == :error
assert SnmpOid.cast(".1.3.6.") == :error
assert SnmpOid.cast(".1.3.-6") == :error
assert SnmpOid.cast(".abc.def") == :error
end
test "rejects list with negative integers" do
assert SnmpOid.cast([1, 3, -6, 1]) == :error
end
test "rejects list with non-integers" do
assert SnmpOid.cast([1, 3, "6", 1]) == :error
assert SnmpOid.cast([1.5, 3.2]) == :error
end
test "rejects unsupported types" do
assert SnmpOid.cast(12_345) == :error
assert SnmpOid.cast(3.14) == :error
assert SnmpOid.cast(true) == :error
assert SnmpOid.cast(%{oid: ".1.3.6.1"}) == :error
end
end
describe "load/1" do
test "loads nil" do
assert SnmpOid.load(nil) == {:ok, nil}
end
test "loads valid OID strings from database" do
{:ok, oid} = SnmpOid.load(".1.3.6.1.2.1.1.1.0")
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
assert oid.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
end
test "loads OID without leading dot" do
{:ok, oid} = SnmpOid.load("1.3.6.1.2.1.1.1.0")
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
end
test "returns error for invalid strings" do
assert SnmpOid.load("not-an-oid") == :error
assert SnmpOid.load(".1..3.6") == :error
end
test "returns error for non-string types" do
assert SnmpOid.load(12_345) == :error
assert SnmpOid.load([1, 3, 6, 1]) == :error
end
end
describe "dump/1" do
test "dumps nil" do
assert SnmpOid.dump(nil) == {:ok, nil}
end
test "dumps OID struct to dotted-decimal string with leading dot" do
{:ok, oid} = SnmpOid.cast("1.3.6.1.2.1.1.1.0")
assert SnmpOid.dump(oid) == {:ok, ".1.3.6.1.2.1.1.1.0"}
end
test "returns error for invalid input" do
assert SnmpOid.dump(".1.3.6.1.2.1.1.1.0") == :error
assert SnmpOid.dump(12_345) == :error
assert SnmpOid.dump([1, 3, 6, 1]) == :error
assert SnmpOid.dump(%{oid: ".1.3.6.1"}) == :error
end
end
describe "cast -> dump -> load roundtrip" do
test "nil roundtrips correctly" do
{:ok, casted} = SnmpOid.cast(nil)
{:ok, dumped} = SnmpOid.dump(casted)
{:ok, loaded} = SnmpOid.load(dumped)
assert loaded == nil
end
test "numeric OID string roundtrips correctly" do
{:ok, casted} = SnmpOid.cast(".1.3.6.1.2.1.1.1.0")
{:ok, dumped} = SnmpOid.dump(casted)
{:ok, loaded} = SnmpOid.load(dumped)
assert loaded.oid == ".1.3.6.1.2.1.1.1.0"
assert loaded.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
end
test "OID without leading dot roundtrips with leading dot added" do
{:ok, casted} = SnmpOid.cast("1.3.6.1.2.1.1.1.0")
{:ok, dumped} = SnmpOid.dump(casted)
{:ok, loaded} = SnmpOid.load(dumped)
assert loaded.oid == ".1.3.6.1.2.1.1.1.0"
end
test "integer list roundtrips correctly" do
{:ok, casted} = SnmpOid.cast([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:ok, dumped} = SnmpOid.dump(casted)
{:ok, loaded} = SnmpOid.load(dumped)
assert loaded.oid == ".1.3.6.1.2.1.1.1.0"
assert loaded.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
end
end
describe "new/1" do
test "creates SnmpOid from valid string" do
{:ok, oid} = SnmpOid.new(".1.3.6.1.2.1.1.1.0")
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
end
test "creates SnmpOid from valid list" do
{:ok, oid} = SnmpOid.new([1, 3, 6, 1, 2, 1, 1, 1, 0])
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
end
test "returns error for invalid input" do
assert SnmpOid.new("invalid") == :error
assert SnmpOid.new(".1..3.6") == :error
end
end
describe "new!/1" do
test "creates SnmpOid from valid string" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
end
test "creates SnmpOid from valid list" do
oid = SnmpOid.new!([1, 3, 6, 1, 2, 1, 1, 1, 0])
assert %SnmpOid{} = oid
assert oid.oid == ".1.3.6.1.2.1.1.1.0"
end
test "raises ArgumentError for invalid input" do
assert_raise ArgumentError, ~r/invalid SNMP OID/, fn ->
SnmpOid.new!("invalid")
end
assert_raise ArgumentError, ~r/invalid SNMP OID/, fn ->
SnmpOid.new!(".1..3.6")
end
end
end
describe "to_string/1" do
test "converts OID struct to dotted-decimal string" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
assert SnmpOid.to_string(oid) == ".1.3.6.1.2.1.1.1.0"
end
test "preserves leading dot" do
oid = SnmpOid.new!("1.3.6.1.2.1.1.1.0")
assert SnmpOid.to_string(oid) == ".1.3.6.1.2.1.1.1.0"
end
end
describe "to_list/1" do
test "converts OID struct to integer list" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
assert SnmpOid.to_list(oid) == [1, 3, 6, 1, 2, 1, 1, 1, 0]
end
test "handles single-part OIDs" do
oid = SnmpOid.new!(".1")
assert SnmpOid.to_list(oid) == [1]
end
end
describe "parent/1" do
test "returns parent OID by removing last part" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
parent = SnmpOid.parent(oid)
assert parent.oid == ".1.3.6.1.2.1.1.1"
assert parent.parts == [1, 3, 6, 1, 2, 1, 1, 1]
end
test "returns same OID for single-part OIDs" do
oid = SnmpOid.new!(".1")
parent = SnmpOid.parent(oid)
assert parent.oid == ".1"
end
test "can get grandparent by calling parent twice" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
grandparent = oid |> SnmpOid.parent() |> SnmpOid.parent()
assert grandparent.oid == ".1.3.6.1.2.1.1"
end
end
describe "child_of?/2" do
test "returns true for direct child" do
parent = SnmpOid.new!(".1.3.6.1.2.1.1")
child = SnmpOid.new!(".1.3.6.1.2.1.1.1")
assert SnmpOid.child_of?(child, parent) == true
end
test "returns true for nested child" do
parent = SnmpOid.new!(".1.3.6.1.2.1")
child = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
assert SnmpOid.child_of?(child, parent) == true
end
test "returns false for parent of child" do
parent = SnmpOid.new!(".1.3.6.1.2.1.1")
child = SnmpOid.new!(".1.3.6.1.2.1.1.1")
assert SnmpOid.child_of?(parent, child) == false
end
test "returns false for sibling OIDs" do
oid1 = SnmpOid.new!(".1.3.6.1.2.1.1.1")
oid2 = SnmpOid.new!(".1.3.6.1.2.1.1.2")
assert SnmpOid.child_of?(oid1, oid2) == false
end
test "returns false for unrelated OIDs" do
oid1 = SnmpOid.new!(".1.3.6.1.2.1.1.1")
oid2 = SnmpOid.new!(".1.3.6.1.4.1.9999")
assert SnmpOid.child_of?(oid1, oid2) == false
end
test "returns false for same OID" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1")
assert SnmpOid.child_of?(oid, oid) == false
end
end
describe "append/2" do
test "appends single part to OID" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1")
extended = SnmpOid.append(oid, [1])
assert extended.oid == ".1.3.6.1.2.1.1.1"
assert extended.parts == [1, 3, 6, 1, 2, 1, 1, 1]
end
test "appends multiple parts to OID" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1")
extended = SnmpOid.append(oid, [1, 0])
assert extended.oid == ".1.3.6.1.2.1.1.1.0"
assert extended.parts == [1, 3, 6, 1, 2, 1, 1, 1, 0]
end
test "appending empty list returns same OID" do
oid = SnmpOid.new!(".1.3.6.1.2.1.1.1")
extended = SnmpOid.append(oid, [])
assert extended.oid == ".1.3.6.1.2.1.1.1"
end
test "clears named field after appending" do
oid = %SnmpOid{oid: ".1.3.6.1.2.1.1.1", named: "sysDescr", parts: [1, 3, 6, 1, 2, 1, 1, 1]}
extended = SnmpOid.append(oid, [0])
assert extended.named == nil
end
end
describe "common SNMP OIDs" do
test "system OIDs" do
system_oid = SnmpOid.new!(".1.3.6.1.2.1.1")
assert system_oid.parts == [1, 3, 6, 1, 2, 1, 1]
end
test "interfaces OIDs" do
interfaces_oid = SnmpOid.new!(".1.3.6.1.2.1.2")
assert interfaces_oid.parts == [1, 3, 6, 1, 2, 1, 2]
end
test "enterprise OIDs" do
enterprises_oid = SnmpOid.new!(".1.3.6.1.4.1")
assert enterprises_oid.parts == [1, 3, 6, 1, 4, 1]
end
test "can build instance OID by appending" do
base = SnmpOid.new!(".1.3.6.1.2.1.1.1")
instance = SnmpOid.append(base, [0])
assert instance.oid == ".1.3.6.1.2.1.1.1.0"
end
end
end