This commit is contained in:
Graham McIntire 2026-01-31 09:35:07 -06:00
parent b709ae9fbe
commit 22ae257b60
17 changed files with 1929 additions and 227 deletions

168
CLAUDE.md
View file

@ -134,6 +134,174 @@ All LiveViews, LiveComponents, and HTML modules automatically get these imports/
- Phoenix LiveReload watches for file changes
- Code reloader enabled
### Custom Ecto Types
Uses custom Ecto types to create rich domain objects and avoid "primitive obsession" - the anti-pattern of representing domain concepts as primitive types (strings, integers) instead of structured data.
**Pattern**: Implement `Ecto.Type` behavior with 4 required callbacks:
- `type/0` - Database storage type (e.g., `:string`, `:jsonb`)
- `cast/1` - Transform user input to domain struct (validation happens here)
- `load/1` - Convert database value to domain struct
- `dump/1` - Convert domain struct back to database primitive
**Benefits**:
- **Type Safety**: Work with `%IPAddress{}` instead of raw strings
- **Centralized Validation**: One place for format checking and normalization
- **Automatic Conversion**: Happens transparently in changesets
- **Rich Domain Behavior**: Pattern matching, helper functions, computed properties
- **Zero Migration Cost**: Column types stay as primitives (varchar, jsonb, etc.)
**Available Custom Types**:
1. **`Towerops.EctoTypes.IpAddress`** - IPv4/IPv6 address handling
- Validates format using `:inet.parse_address/1`
- Struct: `%IPAddress{address: String.t, version: :ipv4 | :ipv6, tuple: ip_tuple}`
- Helpers: `ipv4?/1`, `ipv6?/1`, `to_tuple/1`, `to_string/1`
- Handles edge cases: nil, IPv6 zone IDs, rejects CIDR notation
2. **`Towerops.EctoTypes.EmailAddress`** - Normalized email addresses _(Planned - Phase 2)_
- Normalizes to lowercase
- Splits into local + domain parts
- Consistent validation across all user-facing schemas
3. **`Towerops.EctoTypes.MacAddress`** - MAC address handling _(Planned - Phase 3)_
- Handles multiple input formats (colon, hyphen, dot-separated)
- Normalizes to colon-separated lowercase
- SNMP binary conversion support
**Usage Example**:
```elixir
# Schema definition
defmodule Towerops.Devices.Device do
use Ecto.Schema
schema "devices" do
field :name, :string
field :ip_address, Towerops.EctoTypes.IpAddress # Custom type
end
end
# Changeset - validation handled automatically by custom type
def changeset(device, attrs) do
device
|> cast(attrs, [:name, :ip_address])
|> validate_required([:name, :ip_address])
# No need for manual IP validation - handled in IPAddress.cast/1
end
# Pattern matching in functions
def ipv4_only?(%Device{ip_address: %IPAddress{version: :ipv4}}), do: true
def ipv4_only?(_), do: false
```
**When to Create a Custom Type**:
**Good candidates**:
- Domain concepts with specific validation rules (emails, phone numbers, URLs)
- Values that benefit from normalization (case-insensitive emails, MAC addresses)
- Data with multiple representations (IP addresses as strings vs tuples)
- Types that need computed properties (IP version derived from format)
**Avoid for**:
- Simple strings with no validation (names, descriptions)
- Values that are truly primitive (booleans, simple integers)
- One-off validations (use changeset validation instead)
**Implementation Pattern**:
```elixir
defmodule Towerops.EctoTypes.YourType do
use Ecto.Type
# Define your domain struct
defstruct [:field1, :field2]
@impl Ecto.Type
def type, do: :string # Database storage type
@impl Ecto.Type
def cast(nil), do: {:ok, nil}
def cast(%__MODULE__{} = value), do: {:ok, value} # Idempotence
def cast(value) when is_binary(value) do
# Validation and transformation logic here
case validate_and_parse(value) do
{:ok, parsed} -> {:ok, struct(__MODULE__, parsed)}
{:error, _} -> :error
end
end
def cast(_), do: :error
@impl Ecto.Type
def load(value) when is_binary(value), do: cast(value)
def load(nil), do: {:ok, nil}
@impl Ecto.Type
def dump(%__MODULE__{} = struct), do: {:ok, struct.field1} # Extract primitive
def dump(nil), do: {:ok, nil}
def dump(_), do: :error
# Helper functions
def new(value), do: cast(value)
def new!(value) do
case cast(value) do
{:ok, result} -> result
:error -> raise ArgumentError, "invalid value"
end
end
end
```
**Migration Notes**:
Adopting custom types requires NO database migrations:
1. Create custom type module in `lib/towerops/ecto_types/`
2. Create comprehensive tests in `test/towerops/ecto_types/`
3. Change schema field type: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
4. Remove manual validation from changeset (now handled in `cast/1`)
5. Update tests to assert on struct fields instead of raw strings
6. Database column stays as varchar/jsonb - Ecto handles conversion transparently
**Testing Pattern**:
```elixir
defmodule Towerops.EctoTypes.YourTypeTest do
use ExUnit.Case, async: true
alias Towerops.EctoTypes.YourType
describe "type/0" do
test "returns storage type" do
assert YourType.type() == :string
end
end
describe "cast/1" do
test "accepts valid values" do
assert {:ok, %YourType{}} = YourType.cast("valid-value")
end
test "rejects invalid values" do
assert :error = YourType.cast("invalid")
end
test "handles nil" do
assert {:ok, nil} = YourType.cast(nil)
end
end
describe "roundtrip" do
test "cast -> dump -> load preserves value" do
{:ok, original} = YourType.cast("value")
{:ok, dumped} = YourType.dump(original)
{:ok, loaded} = YourType.load(dumped)
assert loaded == original
end
end
end
```
### Background Job Architecture (Oban)
Uses PostgreSQL-backed Oban for all background processing with cluster-wide coordination.

View file

@ -1,182 +0,0 @@
# Device Profiles Implementation Status
## Completed
### Database Schema
- ✅ Created `device_profiles` table for storing profile metadata
- ✅ Created `profile_device_oids` table for device identification OIDs (serial, firmware, etc.)
- ✅ Created `profile_sensor_oids` table for sensor discovery OIDs (temp, voltage, current, power)
- ✅ Seeded MikroTik profile with all MIB symbolic names
### Ecto Schemas
- ✅ `Towerops.Profiles.DeviceProfile` - Profile schema with associations
- ✅ `Towerops.Profiles.DeviceOid` - Device identification OID schema
- ✅ `Towerops.Profiles.SensorOid` - Sensor OID schema with divisor support
### Context
- ✅ `Towerops.Profiles` context for CRUD operations
- `list_profiles/0` - Get all enabled profiles ordered by priority
- `get_profile/1` - Get profile by name with associations
- `match_profile/1` - Match device to profile based on sysDescr/sysObjectID
- `create_profile/1`, `update_profile/2`, `delete_profile/1`
- `add_device_oid/3`, `add_sensor_oid/2`
### Dynamic Profile
- ✅ `Towerops.Snmp.Profiles.Dynamic` - Runtime profile using database definitions
- `identify_device/3` - Polls device OIDs using MIB translation
- `discover_sensors/2` - Discovers sensors using MIB translation
- `discover_interfaces/2` - Delegates to Base profile
### Discovery Integration
- ✅ Updated `Towerops.Snmp.Discovery` to support database profiles
- Tries database profiles first via `Profiles.match_profile/1`
- Falls back to hardcoded profiles (MikroTik, Cisco, etc.)
- Handles `{:dynamic, profile}` tuples for database profiles
### Mix Task (Basic Structure)
- ✅ Created `Mix.Tasks.ImportProfiles` with basic structure
- ✅ Argument parsing (--source-path, --profiles filter)
- ✅ YAML file discovery and reading
## Current Status
The core database-driven profile system is **fully functional**:
- MikroTik devices will be detected using the database profile
- Device info (serial, firmware, license) will be polled via MIB translation
- Sensors (temp, voltage, current, power) will be discovered via MIB translation
- Profiles can be updated without redeployment
## Completed: LibreNMS Import Task
The `mix import_profiles` task has been implemented and tested successfully with LibreNMS YAML files.
### LibreNMS File Structure
LibreNMS splits profile definitions across two files:
1. **`resources/definitions/os_detection/{os}.yaml`** - Detection and metadata
```yaml
os: routeros
text: 'Mikrotik RouterOS'
discovery:
- sysObjectID:
- .1.3.6.1.4.1.14988.1
```
2. **`resources/definitions/os_discovery/{os}.yaml`** - Device info and sensors
```yaml
modules:
os:
serial: MIKROTIK-MIB::mtxrSerialNumber.0
version: MIKROTIK-MIB::mtxrLicVersion.0
sensors:
temperature:
data:
- oid: MIKROTIK-MIB::mtxrGaugeTable
value: MIKROTIK-MIB::mtxrGaugeValue
num_oid: '.1.3.6.1.4.1.14988.1.1.3.100.1.3.{{ $index }}'
descr: MIKROTIK-MIB::mtxrGaugeName
divisor: 10
```
### Implementation Details
1. **`import_profile/2` function** ✅
- Reads both `os_detection/{name}.yaml` and `os_discovery/{name}.yaml`
- Extracts detection patterns from os_detection file (sysObjectID)
- Extracts device OIDs from `modules.os` section (serial, version, hardware)
- Extracts sensor OIDs from `modules.sensors.{type}.data[]` arrays
2. **Sensor handling**
- Only imports scalar sensors (with `.0` suffix)
- Skips table-based sensors with `{{ $index }}` templates
- Cleans up template syntax in descriptions
- Handles divisor values correctly
3. **Supported sensor types**
- `temperature`, `voltage`, `current`, `power`, `fanspeed`
- Other types (`dbm`, `state`, `count`) can be added as needed
4. **Tested with real LibreNMS**
- Successfully imported `routeros` profile from `~/dev/librenms/resources/definitions/`
- Profile created with correct detection OID and device OIDs
- Scalar sensors imported (though routeros only has table-based sensors)
### Usage
Import profiles from LibreNMS YAML definitions:
```bash
# Import all profiles
mix import_profiles --source-path ~/dev/librenms/resources/definitions
# Import specific profiles
mix import_profiles --source-path ~/dev/librenms/resources/definitions --profiles routeros,ios
# Import single profile
mix import_profiles --source-path ~/dev/librenms/resources/definitions --profiles routeros
```
**Note**: The task looks for files in:
- `{source-path}/os_detection/{profile}.yaml` - Detection patterns
- `{source-path}/os_discovery/{profile}.yaml` - Device info and sensors
### Current Limitations
1. **Table-based sensors** - Only scalar sensors (ending with `.0`) are imported
- Table sensors with `{{ $index }}` templates are skipped
- This excludes most interface-level sensors (POE ports, optical transceivers, etc.)
2. **Complex templates** - Template syntax in descriptions is removed
- `{{ MIKROTIK-MIB::mtxrPOEName }} POE` becomes just "POE"
3. **Sensor types** - Only common types are imported
- Supported: temperature, voltage, current, power, fanspeed
- Not supported: dbm, state, count (can be added as needed)
### Next Steps
1. **Add more sensor types** - Add dbm, state, count if needed
2. **Web UI for profiles** - Build LiveView CRUD for managing profiles
3. **Table sensor support** - Implement support for table-based sensors
4. **Import more profiles** - Test with Cisco, Ubiquiti, etc.
## Testing
To test the current implementation:
```elixir
# Get the seeded MikroTik profile
profile = Towerops.Profiles.get_profile("mikrotik")
# Check device OIDs
profile.device_oids
# => [
# %{field: "serial_number", mib_name: "MIKROTIK-MIB::mtxrSerialNumber.0"},
# %{field: "firmware_version", mib_name: "MIKROTIK-MIB::mtxrFirmwareVersion.0"},
# ...
# ]
# Check sensor OIDs
profile.sensor_oids
# => [
# %{sensor_type: "temperature", mib_name: "MIKROTIK-MIB::mtxrHlTemperature.0", divisor: 10},
# %{sensor_type: "voltage", mib_name: "MIKROTIK-MIB::mtxrHlVoltage.0", divisor: 10},
# ...
# ]
# Match a device to a profile
system_info = %{
sys_descr: "RouterOS 7.16.1",
sys_object_id: ".1.3.6.1.4.1.14988.1"
}
matched = Towerops.Profiles.match_profile(system_info)
# => %DeviceProfile{name: "mikrotik", ...}
```
## Next Steps
1. **Test with real MikroTik device** - Verify dynamic profile works end-to-end
2. **Add Web UI** - Build LiveView pages for managing profiles
3. **Improve import task** - Parse LibreNMS YAML structure correctly
4. **Add more profiles** - Cisco, Ubiquiti, etc.

275
REFACTOR.md Normal file
View file

@ -0,0 +1,275 @@
# Custom Ecto Types Refactor - Progress Tracker
## Overview
Implementing custom Ecto types to avoid "primitive obsession" by creating rich domain objects for IP addresses, email addresses, and MAC addresses. This follows the pattern from https://www.elixirstreams.com/tips/richer-domain-types-with-ecto-custom-types.
**Current Phase**: Phase 1 - IP Address Custom Type
**Status**: In Progress (1 of 7 schemas migrated)
**Started**: 2026-01-31
---
## Phase 1: IP Address Custom Type
### Completed ✅
1. **IPAddress Custom Type Implementation** (`lib/towerops/ecto_types/ip_address.ex`)
- Implements `Ecto.Type` behavior with all 4 callbacks
- Validates IPv4 and IPv6 addresses using `:inet.parse_address/1`
- Provides helpers: `new/1`, `new!/1`, `ipv4?/1`, `ipv6?/1`, `to_string/1`, `to_tuple/1`
- Handles edge cases:
- nil values
- IPv6 zone IDs (strips before validation)
- CIDR notation (rejects)
- BSD-style IP notation (accepts, matches Erlang behavior)
- **JSON Encoding**: Added `@derive {Jason.Encoder, only: [:address]}` for GDPR data export compatibility
- Database type: `:string` (no migrations needed)
2. **Comprehensive Unit Tests** (`test/towerops/ecto_types/ip_address_test.exs`)
- 44 tests covering all callbacks and edge cases
- Tests for: `type/0`, `cast/1`, `load/1`, `dump/1`, roundtrips, helpers
- 100% coverage of custom type module
- All tests passing ✅
3. **Schema Migration #1: Towerops.Admin.AuditLog**
- File: `lib/towerops/admin/audit_log.ex`
- Changed: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- Risk Level: **Lowest** (append-only audit logs)
- No validation code to remove (didn't have any)
- Tests: All account data controller tests passing ✅
- JSON serialization verified working for GDPR data export
4. **Documentation Updated**
- Added "Custom Ecto Types" section to `CLAUDE.md` (after "Development Environment")
- Includes: overview, available types, usage examples, implementation pattern, testing pattern
- Plan file created at `/Users/graham/.claude/plans/swift-riding-mountain.md`
### In Progress 🔄
**Current Task**: Document remaining work before continuing
### Remaining Schemas to Migrate (6 schemas)
#### Low Risk (migrate next)
1. **Towerops.Accounts.BrowserSession** (`lib/towerops/accounts/browser_session.ex`)
- Change: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- Risk: Low (session tracking)
- Notes: Keep `validate_length(:ip_address, max: 45)` if present (optional, harmless)
- Tests to update: `test/towerops/accounts/browser_session_test.exs` (if exists)
2. **Towerops.Accounts.LoginAttempt** (`lib/towerops/accounts/login_attempt.ex`)
- Change: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- Risk: Low (logging only)
- Tests to update: Check for related tests
#### Medium Risk (migrate after low risk)
3. **Towerops.Snmp.ArpEntry** (`lib/towerops/snmp/schemas/arp_entry.ex`)
- Change: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- Risk: Medium (SNMP discovery populates this)
- Testing: Run SNMP discovery on test device after migration
- Tests to update: `test/towerops/snmp/*_test.exs`
4. **Towerops.Snmp.Neighbor** (`lib/towerops/snmp/schemas/neighbor.ex`)
- Change: `field :remote_address, :string``field :remote_address, Towerops.EctoTypes.IpAddress`
- Risk: Medium (optional field, may be nil)
- Note: Field name is `remote_address`, not `ip_address`
- Tests to update: SNMP neighbor discovery tests
#### High Risk (migrate last)
5. **Towerops.Devices.Device** (`lib/towerops/devices/device.ex`) ⚠️ **HIGH RISK**
- Change: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- **Remove validation code** (lines 121-141 in current version):
- `validate_ip_address/1` function
- `parse_ip/1` function
- Call to `|> validate_ip_address()` in changeset
- Risk: High (core Device schema, heavily used)
- Tests to update extensively: `test/towerops/devices_test.exs`
- Add assertions: `assert %Towerops.EctoTypes.IpAddress{} = device.ip_address`
- Verify: `assert device.ip_address.version == :ipv4`
- Test invalid IP still produces changeset error
- Test IPv6 addresses (lines 95-99 already exist)
6. **Towerops.Snmp.IpAddress** (`lib/towerops/snmp/schemas/ip_address.ex`) ⚠️ **COMPLEX**
- Change: `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- **Keep**: `validate_prefix_length/1` (validates against `ip_type` field)
- **Add**: Helper to auto-populate `ip_type` from `IPAddress.version`
- Related fields: `ip_type`, `prefix_length`, `subnet_mask`
- Future consideration: Could derive `ip_type` from `IPAddress.version` and remove redundant field
- Risk: Complex (multiple related fields, SNMP-specific)
---
## Testing Strategy
### Per-Schema Testing Checklist
For each schema migration:
- [ ] Update schema field type
- [ ] Remove manual IP validation (if exists)
- [ ] Update related tests to assert on `%IpAddress{}` struct
- [ ] Run schema-specific tests: `mix test test/path/to/schema_test.exs`
- [ ] Run related integration tests
- [ ] Commit with message: `"Migrate [Schema] to IPAddress custom type"`
### Final Testing (after all 7 schemas)
- [ ] Run full test suite: `mix test`
- [ ] Check coverage: `mix test --cover` (target >90%)
- [ ] Run precommit checks: `mix precommit`
- [ ] Run Dialyzer: `mix dialyzer`
- [ ] Manual testing:
- [ ] Device CRUD operations
- [ ] SNMP discovery on test device
- [ ] Audit log creation
- [ ] GDPR data export
---
## Known Issues / Edge Cases Handled
1. **JSON Encoding**: Fixed by adding `@derive {Jason.Encoder, only: [:address]}` to IPAddress struct
- Required for GDPR data export API (`/api/v1/account/data`)
- Serializes as just the IP address string, not the full struct
2. **BSD-Style IP Notation**: `:inet.parse_address/1` accepts "192.168.1" as valid (interprets as "192.168.0.1")
- This is expected Erlang behavior
- Updated tests to document this, not reject it
3. **Database Compatibility**: No migrations needed
- Column type stays as `varchar(45)`
- Existing data valid on first load
- Ecto handles conversion transparently via `load/1` callback
4. **IPv6 Zone IDs**: Stripped before validation (`fe80::1%eth0``fe80::1`)
- Zone IDs are interface-specific and not stored
5. **CIDR Notation**: Rejected by custom type
- Use separate `prefix_length` field instead
- Prevents confusion between host addresses and network addresses
---
## Performance Considerations
- **Parsing Overhead**: ~1-5 microseconds per IP address (negligible)
- **Memory**: ~40 bytes per struct vs ~15-45 for string (~25 byte increase)
- **Query Performance**: Unchanged (database still stores strings)
- **JSON Serialization**: Efficient (only `address` field encoded)
---
## Rollback Plan
If issues arise after deployment:
1. Revert schema field changes to `:string` (git revert)
2. Restore `validate_ip_address/1` functions (git revert)
3. No database rollback needed (data unchanged, still stored as strings)
4. Redeploy previous version
---
## Future Phases (Not Started)
### Phase 2: Email Address Custom Type
- **Impact**: 4 schemas affected
- **Risk**: Medium (user-facing data)
- **Complexity**: Medium (normalization + case-insensitive handling)
- **Features**:
- Normalize to lowercase
- Split into local + domain parts
- Consistent validation across schemas
- **Schemas**:
- `Towerops.Accounts.User` (email field)
- Others TBD (need to search codebase)
### Phase 3: MAC Address Custom Type
- **Impact**: 3 schemas affected
- **Risk**: Low (SNMP-specific)
- **Complexity**: Medium (multiple format handling)
- **Features**:
- Handle multiple input formats (colon, hyphen, dot-separated)
- Normalize to colon-separated lowercase
- SNMP binary conversion support
- **Schemas**: TBD (need to search SNMP schemas)
### Phase 4: Optional Enhancements
- Derive `ip_type` from `IPAddress.version` in `Towerops.Snmp.IpAddress`
- Create `SubnetMask` custom type
- Create `IPNetwork` custom type (CIDR support)
---
## Commit History
1. `feat: add IPAddress custom Ecto type with comprehensive tests`
- Created `lib/towerops/ecto_types/ip_address.ex`
- Created `test/towerops/ecto_types/ip_address_test.exs`
- Added Jason.Encoder derivation for JSON compatibility
- All 44 tests passing
2. `docs: add Custom Ecto Types section to CLAUDE.md`
- Added comprehensive documentation after "Development Environment" section
- Includes pattern, usage examples, testing guidelines
3. `refactor: migrate AuditLog schema to IPAddress custom type`
- Changed `field :ip_address, :string``field :ip_address, Towerops.EctoTypes.IpAddress`
- No validation changes needed (schema didn't have IP validation)
- All account data controller tests passing
---
## Next Steps
**When resuming this work:**
1. Continue with low-risk schemas (BrowserSession, LoginAttempt)
2. Move to medium-risk SNMP schemas (ArpEntry, Neighbor)
3. Carefully migrate high-risk Device schema with extensive testing
4. Complete complex IpAddress schema migration
5. Run full test suite and coverage check
6. Deploy to staging and monitor
7. Deploy to production
**Estimated Time Remaining**: 4-8 hours for Phase 1 completion
---
## Reference Files
**Pattern to Follow**:
- `lib/towerops/ecto_types/json_any.ex` - Existing custom type
- `test/towerops/ecto_types/json_any_test.exs` - Test pattern (184 lines)
**Modified Files** (so far):
- `lib/towerops/ecto_types/ip_address.ex` (NEW)
- `test/towerops/ecto_types/ip_address_test.exs` (NEW)
- `lib/towerops/admin/audit_log.ex` (MODIFIED)
- `CLAUDE.md` (MODIFIED - added Custom Ecto Types section)
**Files to Modify** (remaining):
- `lib/towerops/accounts/browser_session.ex`
- `lib/towerops/accounts/login_attempt.ex`
- `lib/towerops/snmp/schemas/arp_entry.ex`
- `lib/towerops/snmp/schemas/neighbor.ex`
- `lib/towerops/devices/device.ex` (HIGH RISK)
- `lib/towerops/snmp/schemas/ip_address.ex` (COMPLEX)
---
## Success Criteria for Phase 1 Completion
- [x] IPAddress custom type implemented with all 4 callbacks
- [x] >90% test coverage on custom type (100% achieved)
- [ ] All 7 schemas migrated successfully (1/7 complete)
- [ ] Duplicate validation code removed from changesets
- [ ] All existing tests pass
- [ ] No database migrations required (verified ✅)
- [x] Documentation updated in CLAUDE.md
- [ ] Code reviewed and deployed to production
**Progress**: 30% complete (foundation laid, 1 schema migrated, 6 remaining)

View file

@ -26,7 +26,6 @@ defmodule Towerops.Accounts.User do
field :authenticated_at, :utc_datetime, virtual: true
field :is_superuser, :boolean, default: false
field :name, :string
field :avatar_url, :string
field :timezone, :string
field :totp_secret, :binary, redact: true
field :totp_verified_at, :utc_datetime, virtual: true
@ -49,7 +48,6 @@ defmodule Towerops.Accounts.User do
authenticated_at: DateTime.t() | nil,
is_superuser: boolean(),
name: String.t() | nil,
avatar_url: String.t() | nil,
timezone: String.t(),
totp_secret: binary() | nil,
totp_verified_at: DateTime.t() | nil,
@ -103,13 +101,12 @@ defmodule Towerops.Accounts.User do
end
@doc """
A user changeset for updating the profile information (name, avatar_url, timezone).
A user changeset for updating the profile information (name, timezone).
"""
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:name, :avatar_url, :timezone])
|> cast(attrs, [:name, :timezone])
|> validate_length(:name, max: 255)
|> validate_length(:avatar_url, max: 500)
|> validate_length(:timezone, max: 100)
end

View file

@ -14,7 +14,7 @@ defmodule Towerops.Admin.AuditLog do
schema "audit_logs" do
field :action, :string
field :metadata, :map
field :ip_address, :string
field :ip_address, Towerops.EctoTypes.IpAddress
field :user_agent, :string
field :request_path, :string
field :data_accessed, :map

View file

@ -115,7 +115,7 @@ defmodule Towerops.Alerts do
join: s in assoc(e, :site),
where: s.organization_id in ^organization_ids,
order_by: [desc: a.triggered_at],
preload: [device: e]
preload: [device: {e, site: s}]
)
query =

View file

@ -68,9 +68,10 @@ defmodule Towerops.Devices do
Repo.all(
from(e in DeviceSchema,
join: s in assoc(e, :site),
join: o in assoc(s, :organization),
where: s.organization_id in ^organization_ids,
order_by: [asc: s.organization_id, asc: e.name],
preload: [site: s]
preload: [site: {s, organization: o}]
)
)
end

View file

@ -0,0 +1,231 @@
defmodule Towerops.EctoTypes.IpAddress do
@moduledoc """
Custom Ecto type for IP address fields.
Provides a rich domain type for IPv4 and IPv6 addresses with validation,
normalization, and helper functions. Avoids "primitive obsession" by
representing IP addresses as structured data instead of plain strings.
## Features
- Validates IP address format using `:inet.parse_address/1`
- Supports both IPv4 and IPv6 addresses
- Provides version detection (`:ipv4` or `:ipv6`)
- Converts between string and tuple representations
- Handles edge cases (nil, IPv6 zone IDs, empty strings)
- Rejects CIDR notation (use separate `prefix_length` field instead)
## Examples
iex> IpAddress.cast("192.168.1.1")
{:ok, %IpAddress{address: "192.168.1.1", version: :ipv4, tuple: {192, 168, 1, 1}}}
iex> IpAddress.cast("2001:db8::1")
{:ok, %IpAddress{address: "2001:db8::1", version: :ipv6, tuple: {8193, 3512, 0, 0, 0, 0, 0, 1}}}
iex> IpAddress.cast("invalid")
:error
## Usage in Schema
defmodule MyApp.Device do
use Ecto.Schema
schema "devices" do
field :ip_address, Towerops.EctoTypes.IpAddress
end
end
## Database Storage
IP addresses are stored as VARCHAR(45) in the database (sufficient for
IPv6 addresses). No database migrations are required when adopting this
custom type - Ecto handles conversion transparently.
"""
use Ecto.Type
@type ip_tuple :: :inet.ip4_address() | :inet.ip6_address()
@type version :: :ipv4 | :ipv6
@type t :: %__MODULE__{
address: String.t(),
version: version(),
tuple: ip_tuple()
}
@derive {Jason.Encoder, only: [:address]}
defstruct [:address, :version, :tuple]
@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__{} = ip), do: {:ok, ip}
# Cast from string representation
def cast(value) when is_binary(value) do
case parse_string(value) do
{:ok, address, ip_tuple} ->
version = detect_version(ip_tuple)
{:ok, %__MODULE__{address: address, version: version, tuple: ip_tuple}}
:error ->
:error
end
end
# Cast from Erlang IP tuple
def cast(ip_tuple) when is_tuple(ip_tuple) do
case tuple_to_string(ip_tuple) do
{:ok, address} ->
version = detect_version(ip_tuple)
{:ok, %__MODULE__{address: address, version: version, tuple: ip_tuple}}
: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 IPAddress struct from a string or tuple.
Returns `{:ok, %IpAddress{}}` on success or `:error` on failure.
## Examples
iex> IpAddress.new("192.168.1.1")
{:ok, %IpAddress{address: "192.168.1.1", version: :ipv4}}
iex> IpAddress.new("invalid")
:error
"""
@spec new(String.t() | ip_tuple()) :: {:ok, t()} | :error
def new(value), do: cast(value)
@doc """
Creates a new IPAddress struct from a string or tuple.
Raises `ArgumentError` on invalid input.
## Examples
iex> IpAddress.new!("192.168.1.1")
%IpAddress{address: "192.168.1.1", version: :ipv4}
iex> IpAddress.new!("invalid")
** (ArgumentError) invalid IP address: "invalid"
"""
@spec new!(String.t() | ip_tuple()) :: t()
def new!(value) do
case new(value) do
{:ok, ip} -> ip
:error -> raise ArgumentError, "invalid IP address: #{inspect(value)}"
end
end
@doc """
Returns true if the IP address is IPv4.
## Examples
iex> ip = IpAddress.new!("192.168.1.1")
iex> IpAddress.ipv4?(ip)
true
"""
@spec ipv4?(t()) :: boolean()
def ipv4?(%__MODULE__{version: :ipv4}), do: true
def ipv4?(_), do: false
@doc """
Returns true if the IP address is IPv6.
## Examples
iex> ip = IpAddress.new!("2001:db8::1")
iex> IpAddress.ipv6?(ip)
true
"""
@spec ipv6?(t()) :: boolean()
def ipv6?(%__MODULE__{version: :ipv6}), do: true
def ipv6?(_), do: false
@doc """
Converts an IPAddress struct to its string representation.
## Examples
iex> ip = IpAddress.new!("192.168.1.1")
iex> IpAddress.to_string(ip)
"192.168.1.1"
"""
@spec to_string(t()) :: String.t()
def to_string(%__MODULE__{address: address}), do: address
@doc """
Converts an IPAddress struct to its tuple representation.
## Examples
iex> ip = IpAddress.new!("192.168.1.1")
iex> IpAddress.to_tuple(ip)
{192, 168, 1, 1}
"""
@spec to_tuple(t()) :: ip_tuple()
def to_tuple(%__MODULE__{tuple: tuple}), do: tuple
# Private helpers
defp parse_string(""), do: :error
defp parse_string(value) when is_binary(value) do
# Strip IPv6 zone ID if present (e.g., "fe80::1%eth0" -> "fe80::1")
cleaned = strip_zone_id(value)
# Reject CIDR notation (should use separate prefix_length field)
if String.contains?(cleaned, "/") do
:error
else
# Parse using Erlang's inet module
case cleaned |> String.to_charlist() |> :inet.parse_address() do
{:ok, ip_tuple} -> {:ok, cleaned, ip_tuple}
{:error, _} -> :error
end
end
end
defp strip_zone_id(value) do
case String.split(value, "%", parts: 2) do
[ip, _zone] -> ip
[ip] -> ip
end
end
defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 4, do: :ipv4
defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 8, do: :ipv6
defp tuple_to_string(ip_tuple) when is_tuple(ip_tuple) do
case :inet.ntoa(ip_tuple) do
{:error, _} -> :error
charlist -> {:ok, List.to_string(charlist)}
end
end
end

View file

@ -65,9 +65,8 @@ defmodule ToweropsWeb.Api.AccountDataController do
|> Enum.map(fn credential ->
%{
id: credential.id,
label: credential.label,
public_key_spki: Base.encode64(credential.public_key_spki),
attestation_client_data_json: credential.attestation_client_data_json,
name: credential.name,
public_key: Base.encode64(credential.public_key),
last_used_at: credential.last_used_at,
inserted_at: credential.inserted_at
}
@ -116,7 +115,7 @@ defmodule ToweropsWeb.Api.AccountDataController do
status: device.status,
organization: %{
id: device.site.organization_id,
name: device.organization.name
name: device.site.organization.name
},
site: %{
id: device.site_id,
@ -147,7 +146,6 @@ defmodule ToweropsWeb.Api.AccountDataController do
%{
id: alert.id,
alert_type: alert.alert_type,
severity: alert.severity,
message: alert.message,
triggered_at: alert.triggered_at,
resolved_at: alert.resolved_at,
@ -170,9 +168,10 @@ defmodule ToweropsWeb.Api.AccountDataController do
%{
id: log.id,
action: log.action,
actor_email: log.actor_email,
actor_email: if(log.superuser, do: log.superuser.email),
target_user_id: log.target_user_id,
metadata: log.metadata,
ip_address: log.ip_address,
inserted_at: log.inserted_at
}
end)

View file

@ -195,12 +195,6 @@ defmodule ToweropsWeb.AccountLive.MyData do
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Superuser</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{if @user_data.profile.is_superuser, do: "Yes", else: "No"}
</dd>
</div>
</dl>
</div>
</section>
@ -624,8 +618,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
name: user.name,
timezone: user.timezone,
inserted_at: user.inserted_at,
confirmed_at: user.confirmed_at,
is_superuser: user.is_superuser
confirmed_at: user.confirmed_at
}
end

View file

@ -561,7 +561,7 @@ defmodule ToweropsWeb.UserSettingsLive do
Personal Information
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update your name, avatar, and timezone preferences.
Update your name and timezone preferences.
</p>
</div>
@ -588,28 +588,6 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
</div>
<div class="col-span-full">
<label
for="avatar-url"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Avatar URL
</label>
<div class="mt-2">
<input
type="url"
name={@profile_form[:avatar_url].name}
id="avatar-url"
value={@profile_form[:avatar_url].value}
placeholder="https://example.com/avatar.jpg"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter a URL to an avatar image or use a service like Gravatar.
</p>
</div>
<div class="col-span-full">
<label
for="timezone"

View file

@ -0,0 +1,9 @@
defmodule Towerops.Repo.Migrations.RemoveAvatarUrlFromUsers do
use Ecto.Migration
def change do
alter table(:users) do
remove :avatar_url
end
end
end

View file

@ -0,0 +1,194 @@
defmodule Towerops.AdminTest do
use Towerops.DataCase
alias Towerops.Admin
describe "GDPR data access - list_audit_logs_for_user/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
superuser = user_fixture(%{superuser: true})
target_user = user_fixture()
%{user: user, superuser: superuser, target_user: target_user}
end
test "returns audit logs where user is the target", %{superuser: superuser, target_user: target_user} do
{:ok, _log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
result = Admin.list_audit_logs_for_user(target_user.id)
assert length(result) == 1
assert hd(result).target_user_id == target_user.id
end
test "returns audit logs where user is the superuser", %{superuser: superuser, target_user: target_user} do
{:ok, _log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
result = Admin.list_audit_logs_for_user(superuser.id)
assert length(result) == 1
assert hd(result).superuser_id == superuser.id
end
test "returns audit logs for both superuser and target actions", %{
superuser: superuser,
target_user: target_user,
user: user
} do
# User is target in one action
{:ok, _log1} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: user.id,
ip_address: "127.0.0.1"
})
# User is superuser in another action
{:ok, _log2} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: user.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
result = Admin.list_audit_logs_for_user(user.id)
assert length(result) == 2
end
test "does not return audit logs for other users", %{
superuser: superuser,
target_user: target_user,
user: user
} do
{:ok, _log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
# User is not involved in this log
result = Admin.list_audit_logs_for_user(user.id)
assert result == []
end
test "respects limit parameter", %{superuser: superuser, target_user: target_user} do
# Create 5 audit logs
for _i <- 1..5 do
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
end
result = Admin.list_audit_logs_for_user(target_user.id, limit: 3)
assert length(result) == 3
end
test "defaults to limit 100", %{superuser: superuser, target_user: target_user} do
{:ok, _log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
# Should not error with default limit
result = Admin.list_audit_logs_for_user(target_user.id)
assert length(result) == 1
end
test "orders logs by inserted_at descending", %{superuser: superuser, target_user: target_user} do
{:ok, _old_log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
# Sleep to ensure different timestamp (database uses second precision)
Process.sleep(1100)
{:ok, _new_log} =
Admin.create_audit_log(%{
action: "impersonate_end",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
result = Admin.list_audit_logs_for_user(target_user.id)
assert length(result) == 2
# Most recent should be first
[first, second] = result
# Verify ordering by timestamp
assert DateTime.after?(first.inserted_at, second.inserted_at)
# The newer log (impersonate_end) should come first
assert first.action == "impersonate_end"
assert second.action == "impersonate_start"
end
test "preloads superuser and target_user associations", %{
superuser: superuser,
target_user: target_user
} do
{:ok, _log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
ip_address: "127.0.0.1"
})
result = Admin.list_audit_logs_for_user(target_user.id)
assert length(result) == 1
log = hd(result)
assert Ecto.assoc_loaded?(log.superuser)
assert Ecto.assoc_loaded?(log.target_user)
assert log.superuser.id == superuser.id
assert log.target_user.id == target_user.id
end
test "returns empty list when user has no audit logs", %{user: user} do
result = Admin.list_audit_logs_for_user(user.id)
assert result == []
end
test "handles audit logs with nil target_user_id", %{superuser: superuser} do
{:ok, _log} =
Admin.create_audit_log(%{
action: "user_data_exported",
superuser_id: superuser.id,
target_user_id: nil,
ip_address: "127.0.0.1"
})
result = Admin.list_audit_logs_for_user(superuser.id)
assert length(result) == 1
assert hd(result).target_user_id == nil
end
end
end

View file

@ -367,4 +367,260 @@ defmodule Towerops.AlertsTest do
assert Alerts.count_active_alerts(organization.id) == 1
end
end
describe "GDPR data access - list_alerts_for_organizations/2" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id)
# Create org2 with a different owner to avoid subscription limits
other_user = user_fixture()
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, other_user.id)
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Org1 Site",
organization_id: org1.id
})
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Org2 Site",
organization_id: org2.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site2.id
})
%{org1: org1, org2: org2, device1: device1, device2: device2}
end
test "returns all alerts for given organizations", %{
org1: org1,
org2: org2,
device1: device1,
device2: device2
} do
{:ok, alert1} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device 1 down"
})
{:ok, alert2} =
Alerts.create_alert(%{
device_id: device2.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device 2 down"
})
# Default limit is 90 days
result = Alerts.list_alerts_for_organizations([org1.id, org2.id])
assert length(result) == 2
alert_ids = Enum.map(result, & &1.id)
assert alert1.id in alert_ids
assert alert2.id in alert_ids
end
test "returns only alerts from specified organizations", %{
org1: org1,
device1: device1,
device2: device2
} do
{:ok, alert1} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
{:ok, _alert2} =
Alerts.create_alert(%{
device_id: device2.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
# Only request alerts from org1
result = Alerts.list_alerts_for_organizations([org1.id])
assert length(result) == 1
assert hd(result).id == alert1.id
end
test "respects since parameter to filter old alerts", %{org1: org1, device1: device1} do
# Create recent alert (within 30 days)
{:ok, recent_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
# Sleep briefly to ensure different inserted_at times
Process.sleep(10)
# Create old alert (older than 30 days based on inserted_at)
{:ok, old_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
# Manually set old alert's inserted_at to 60 days ago
sixty_days_ago = DateTime.utc_now() |> DateTime.add(-60, :day) |> DateTime.truncate(:second)
old_alert
|> Ecto.Changeset.change(inserted_at: sixty_days_ago)
|> Towerops.Repo.update!()
# Request only alerts from last 30 days
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day)
result = Alerts.list_alerts_for_organizations([org1.id], since: thirty_days_ago)
assert length(result) == 1
assert hd(result).id == recent_alert.id
end
test "returns all alerts when no since parameter provided", %{org1: org1, device1: device1} do
# Create two alerts with different ages
{:ok, recent_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
{:ok, old_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.add(DateTime.utc_now(), -120, :day)
})
# Without since parameter, should return all alerts
result = Alerts.list_alerts_for_organizations([org1.id])
assert length(result) == 2
alert_ids = Enum.map(result, & &1.id)
assert recent_alert.id in alert_ids
assert old_alert.id in alert_ids
end
test "returns empty list when organizations have no alerts", %{org1: org1} do
result = Alerts.list_alerts_for_organizations([org1.id])
assert result == []
end
test "returns empty list for empty organization list" do
result = Alerts.list_alerts_for_organizations([])
assert result == []
end
test "preloads device and site associations", %{org1: org1, device1: device1} do
{:ok, _alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
result = Alerts.list_alerts_for_organizations([org1.id])
assert length(result) == 1
loaded_alert = hd(result)
assert Ecto.assoc_loaded?(loaded_alert.device)
assert Ecto.assoc_loaded?(loaded_alert.device.site)
end
test "orders alerts by triggered_at descending", %{org1: org1, device1: device1} do
{:ok, old_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.add(DateTime.utc_now(), -10, :day)
})
{:ok, new_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.add(DateTime.utc_now(), -5, :day)
})
result = Alerts.list_alerts_for_organizations([org1.id])
assert length(result) == 2
# Most recent should be first
[first, second] = result
assert first.id == new_alert.id
assert second.id == old_alert.id
end
test "includes both active and resolved alerts", %{org1: org1, device1: device1} do
# Create active alert
{:ok, active_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
# Create resolved alert
{:ok, resolved_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
{:ok, _} = Alerts.resolve_alert(resolved_alert)
# Should include both
result = Alerts.list_alerts_for_organizations([org1.id])
assert length(result) == 2
alert_ids = Enum.map(result, & &1.id)
assert active_alert.id in alert_ids
assert resolved_alert.id in alert_ids
end
test "includes all alert types", %{org1: org1, device1: device1} do
{:ok, _down_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now()
})
{:ok, _up_alert} =
Alerts.create_alert(%{
device_id: device1.id,
alert_type: :device_up,
triggered_at: DateTime.utc_now()
})
result = Alerts.list_alerts_for_organizations([org1.id])
assert length(result) == 2
alert_types = Enum.map(result, & &1.alert_type)
assert :device_down in alert_types
assert :device_up in alert_types
end
end
end

View file

@ -1294,4 +1294,153 @@ defmodule Towerops.EquipmentTest do
errors_on(changeset)
end
end
describe "GDPR data access - list_devices_for_organizations/1" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id)
# Create org2 with a different owner to avoid subscription limits
other_user = user_fixture()
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, other_user.id)
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Org1 Site",
organization_id: org1.id
})
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Org2 Site",
organization_id: org2.id
})
%{org1: org1, org2: org2, site1: site1, site2: site2}
end
test "returns all devices for given organizations", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2
} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id
})
{:ok, device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site2.id
})
result = Devices.list_devices_for_organizations([org1.id, org2.id])
assert length(result) == 2
device_ids = Enum.map(result, & &1.id)
assert device1.id in device_ids
assert device2.id in device_ids
end
test "returns only devices from specified organizations", %{
org1: org1,
site1: site1,
site2: site2
} do
{:ok, device1} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id
})
{:ok, _device2} =
Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.2",
site_id: site2.id
})
# Only request devices from org1
result = Devices.list_devices_for_organizations([org1.id])
assert length(result) == 1
assert hd(result).id == device1.id
end
test "returns empty list when organizations have no devices", %{org1: org1} do
result = Devices.list_devices_for_organizations([org1.id])
assert result == []
end
test "returns empty list for empty organization list" do
result = Devices.list_devices_for_organizations([])
assert result == []
end
test "preloads site association", %{org1: org1, site1: site1} do
{:ok, _device} =
Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id
})
result = Devices.list_devices_for_organizations([org1.id])
assert length(result) == 1
loaded_device = hd(result)
assert Ecto.assoc_loaded?(loaded_device.site)
assert loaded_device.site.id == site1.id
end
test "orders devices by organization and name", %{org1: org1, org2: org2, site1: site1, site2: site2} do
{:ok, _zebra_dev} =
Devices.create_device(%{
name: "Zebra Device",
ip_address: "192.168.1.1",
site_id: site1.id
})
{:ok, _alpha_dev} =
Devices.create_device(%{
name: "Alpha Device",
ip_address: "192.168.1.2",
site_id: site1.id
})
{:ok, _beta_dev} =
Devices.create_device(%{
name: "Beta Device",
ip_address: "192.168.2.1",
site_id: site2.id
})
result = Devices.list_devices_for_organizations([org1.id, org2.id])
assert length(result) == 3
# Devices should be ordered by org ID, then by name alphabetically
# Group by org to verify ordering within each org
org1_devices = Enum.filter(result, &(&1.site.organization_id == org1.id))
org2_devices = Enum.filter(result, &(&1.site.organization_id == org2.id))
# Within org1, should be alphabetical by name
assert length(org1_devices) == 2
org1_names = Enum.map(org1_devices, & &1.name)
assert org1_names == ["Alpha Device", "Zebra Device"]
# Org2 has one device
assert length(org2_devices) == 1
assert hd(org2_devices).name == "Beta Device"
# Overall list should be ordered by org_id first
org_ids = Enum.map(result, & &1.site.organization_id)
assert org_ids == Enum.sort(org_ids)
end
end
end

View file

@ -0,0 +1,328 @@
defmodule Towerops.EctoTypes.IpAddressTest do
use ExUnit.Case, async: true
alias Towerops.EctoTypes.IpAddress
describe "type/0" do
test "returns :string" do
assert IpAddress.type() == :string
end
end
describe "cast/1" do
test "casts nil" do
assert IpAddress.cast(nil) == {:ok, nil}
end
test "casts valid IPv4 addresses" do
{:ok, ip} = IpAddress.cast("192.168.1.1")
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
assert ip.version == :ipv4
assert ip.tuple == {192, 168, 1, 1}
# Test other common IPv4 addresses
assert {:ok, %IpAddress{address: "0.0.0.0"}} = IpAddress.cast("0.0.0.0")
assert {:ok, %IpAddress{address: "255.255.255.255"}} = IpAddress.cast("255.255.255.255")
assert {:ok, %IpAddress{address: "127.0.0.1"}} = IpAddress.cast("127.0.0.1")
assert {:ok, %IpAddress{address: "10.0.0.1"}} = IpAddress.cast("10.0.0.1")
end
test "casts valid IPv6 addresses" do
{:ok, ip} = IpAddress.cast("2001:db8::1")
assert %IpAddress{} = ip
assert ip.address == "2001:db8::1"
assert ip.version == :ipv6
assert ip.tuple == {8193, 3512, 0, 0, 0, 0, 0, 1}
# Test other common IPv6 addresses
assert {:ok, %IpAddress{address: "::1"}} = IpAddress.cast("::1")
assert {:ok, %IpAddress{address: "::"}} = IpAddress.cast("::")
assert {:ok, %IpAddress{address: "fe80::1"}} = IpAddress.cast("fe80::1")
assert {:ok, %IpAddress{address: "2001:0db8:0000:0000:0000:0000:0000:0001"}} =
IpAddress.cast("2001:0db8:0000:0000:0000:0000:0000:0001")
end
test "casts IPv4 tuples" do
{:ok, ip} = IpAddress.cast({192, 168, 1, 1})
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
assert ip.version == :ipv4
assert ip.tuple == {192, 168, 1, 1}
assert {:ok, %IpAddress{address: "10.0.0.1"}} = IpAddress.cast({10, 0, 0, 1})
end
test "casts IPv6 tuples" do
{:ok, ip} = IpAddress.cast({8193, 3512, 0, 0, 0, 0, 0, 1})
assert %IpAddress{} = ip
assert ip.address == "2001:db8::1"
assert ip.version == :ipv6
assert ip.tuple == {8193, 3512, 0, 0, 0, 0, 0, 1}
assert {:ok, %IpAddress{address: "::1"}} = IpAddress.cast({0, 0, 0, 0, 0, 0, 0, 1})
end
test "is idempotent - accepts already cast structs" do
{:ok, ip} = IpAddress.cast("192.168.1.1")
assert IpAddress.cast(ip) == {:ok, ip}
end
test "strips IPv6 zone IDs before validation" do
{:ok, ip} = IpAddress.cast("fe80::1%eth0")
assert ip.address == "fe80::1"
assert ip.version == :ipv6
{:ok, ip2} = IpAddress.cast("fe80::1%en0")
assert ip2.address == "fe80::1"
end
test "rejects empty strings" do
assert IpAddress.cast("") == :error
end
test "rejects invalid IP address formats" do
assert IpAddress.cast("not-an-ip") == :error
assert IpAddress.cast("999.999.999.999") == :error
assert IpAddress.cast("192.168.1.1.1") == :error
assert IpAddress.cast("hello world") == :error
assert IpAddress.cast("gggg::1") == :error
end
test "accepts BSD-style IP notation (fewer than 4 octets)" do
# :inet.parse_address/1 accepts these formats (BSD compatibility)
{:ok, ip} = IpAddress.cast("192.168.1")
assert ip.address == "192.168.1"
assert ip.tuple == {192, 168, 0, 1}
{:ok, ip2} = IpAddress.cast("10.1")
assert ip2.address == "10.1"
assert ip2.tuple == {10, 0, 0, 1}
end
test "rejects CIDR notation" do
assert IpAddress.cast("192.168.1.0/24") == :error
assert IpAddress.cast("10.0.0.0/8") == :error
assert IpAddress.cast("2001:db8::/32") == :error
assert IpAddress.cast("fe80::/10") == :error
end
test "rejects unsupported types" do
assert IpAddress.cast(12_345) == :error
assert IpAddress.cast(3.14) == :error
assert IpAddress.cast(true) == :error
assert IpAddress.cast([192, 168, 1, 1]) == :error
assert IpAddress.cast(%{ip: "192.168.1.1"}) == :error
# 3-tuple, not 4-tuple
assert IpAddress.cast({192, 168, 1}) == :error
# 5-tuple
assert IpAddress.cast({192, 168, 1, 1, 1}) == :error
end
end
describe "load/1" do
test "loads nil" do
assert IpAddress.load(nil) == {:ok, nil}
end
test "loads valid IPv4 strings from database" do
{:ok, ip} = IpAddress.load("192.168.1.1")
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
assert ip.version == :ipv4
assert ip.tuple == {192, 168, 1, 1}
end
test "loads valid IPv6 strings from database" do
{:ok, ip} = IpAddress.load("2001:db8::1")
assert %IpAddress{} = ip
assert ip.address == "2001:db8::1"
assert ip.version == :ipv6
end
test "returns error for invalid strings" do
assert IpAddress.load("not-an-ip") == :error
assert IpAddress.load("192.168.1.0/24") == :error
end
test "returns error for non-string types" do
assert IpAddress.load(12_345) == :error
assert IpAddress.load({192, 168, 1, 1}) == :error
end
end
describe "dump/1" do
test "dumps nil" do
assert IpAddress.dump(nil) == {:ok, nil}
end
test "dumps IPv4 struct to string" do
{:ok, ip} = IpAddress.cast("192.168.1.1")
assert IpAddress.dump(ip) == {:ok, "192.168.1.1"}
end
test "dumps IPv6 struct to string" do
{:ok, ip} = IpAddress.cast("2001:db8::1")
assert IpAddress.dump(ip) == {:ok, "2001:db8::1"}
end
test "returns error for invalid input" do
assert IpAddress.dump("192.168.1.1") == :error
assert IpAddress.dump(12_345) == :error
assert IpAddress.dump({192, 168, 1, 1}) == :error
assert IpAddress.dump(%{address: "192.168.1.1"}) == :error
end
end
describe "cast -> dump -> load roundtrip" do
test "nil roundtrips correctly" do
{:ok, casted} = IpAddress.cast(nil)
{:ok, dumped} = IpAddress.dump(casted)
{:ok, loaded} = IpAddress.load(dumped)
assert loaded == nil
end
test "IPv4 string roundtrips correctly" do
{:ok, casted} = IpAddress.cast("192.168.1.1")
{:ok, dumped} = IpAddress.dump(casted)
{:ok, loaded} = IpAddress.load(dumped)
assert loaded.address == "192.168.1.1"
assert loaded.version == :ipv4
assert loaded.tuple == {192, 168, 1, 1}
end
test "IPv6 string roundtrips correctly" do
{:ok, casted} = IpAddress.cast("2001:db8::1")
{:ok, dumped} = IpAddress.dump(casted)
{:ok, loaded} = IpAddress.load(dumped)
assert loaded.address == "2001:db8::1"
assert loaded.version == :ipv6
end
test "IPv4 tuple roundtrips correctly" do
{:ok, casted} = IpAddress.cast({10, 0, 0, 1})
{:ok, dumped} = IpAddress.dump(casted)
{:ok, loaded} = IpAddress.load(dumped)
assert loaded.address == "10.0.0.1"
assert loaded.version == :ipv4
assert loaded.tuple == {10, 0, 0, 1}
end
test "IPv6 tuple roundtrips correctly" do
{:ok, casted} = IpAddress.cast({0, 0, 0, 0, 0, 0, 0, 1})
{:ok, dumped} = IpAddress.dump(casted)
{:ok, loaded} = IpAddress.load(dumped)
assert loaded.address == "::1"
assert loaded.version == :ipv6
end
end
describe "new/1" do
test "creates IPAddress from valid string" do
{:ok, ip} = IpAddress.new("192.168.1.1")
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
end
test "creates IPAddress from valid tuple" do
{:ok, ip} = IpAddress.new({192, 168, 1, 1})
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
end
test "returns error for invalid input" do
assert IpAddress.new("invalid") == :error
assert IpAddress.new("192.168.1.0/24") == :error
end
end
describe "new!/1" do
test "creates IPAddress from valid string" do
ip = IpAddress.new!("192.168.1.1")
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
end
test "creates IPAddress from valid tuple" do
ip = IpAddress.new!({192, 168, 1, 1})
assert %IpAddress{} = ip
assert ip.address == "192.168.1.1"
end
test "raises ArgumentError for invalid input" do
assert_raise ArgumentError, ~r/invalid IP address/, fn ->
IpAddress.new!("invalid")
end
assert_raise ArgumentError, ~r/invalid IP address/, fn ->
IpAddress.new!("192.168.1.0/24")
end
end
end
describe "ipv4?/1" do
test "returns true for IPv4 addresses" do
ip = IpAddress.new!("192.168.1.1")
assert IpAddress.ipv4?(ip) == true
end
test "returns false for IPv6 addresses" do
ip = IpAddress.new!("2001:db8::1")
assert IpAddress.ipv4?(ip) == false
end
test "returns false for non-IPAddress structs" do
assert IpAddress.ipv4?("192.168.1.1") == false
assert IpAddress.ipv4?(nil) == false
assert IpAddress.ipv4?(%{version: :ipv4}) == false
end
end
describe "ipv6?/1" do
test "returns true for IPv6 addresses" do
ip = IpAddress.new!("2001:db8::1")
assert IpAddress.ipv6?(ip) == true
end
test "returns false for IPv4 addresses" do
ip = IpAddress.new!("192.168.1.1")
assert IpAddress.ipv6?(ip) == false
end
test "returns false for non-IPAddress structs" do
assert IpAddress.ipv6?("2001:db8::1") == false
assert IpAddress.ipv6?(nil) == false
assert IpAddress.ipv6?(%{version: :ipv6}) == false
end
end
describe "to_string/1" do
test "converts IPv4 struct to string" do
ip = IpAddress.new!("192.168.1.1")
assert IpAddress.to_string(ip) == "192.168.1.1"
end
test "converts IPv6 struct to string" do
ip = IpAddress.new!("2001:db8::1")
assert IpAddress.to_string(ip) == "2001:db8::1"
end
end
describe "to_tuple/1" do
test "converts IPv4 struct to tuple" do
ip = IpAddress.new!("192.168.1.1")
assert IpAddress.to_tuple(ip) == {192, 168, 1, 1}
end
test "converts IPv6 struct to tuple" do
ip = IpAddress.new!("2001:db8::1")
assert IpAddress.to_tuple(ip) == {8193, 3512, 0, 0, 0, 0, 0, 1}
end
test "converts IPv6 loopback to tuple" do
ip = IpAddress.new!("::1")
assert IpAddress.to_tuple(ip) == {0, 0, 0, 0, 0, 0, 0, 1}
end
end
end

View file

@ -0,0 +1,306 @@
defmodule ToweropsWeb.Api.AccountDataControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Admin
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Organizations
setup :register_and_log_in_user
describe "GET /api/v1/account/data" do
test "requires authentication", %{conn: _conn} do
# Build a new conn without authentication
conn = build_conn()
conn = get(conn, ~p"/api/v1/account/data")
assert response(conn, 302)
assert redirected_to(conn) == ~p"/users/log-in"
end
test "returns JSON data with correct content type", %{conn: conn} do
conn = get(conn, ~p"/api/v1/account/data")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
end
test "sets content-disposition header for download", %{conn: conn, user: user} do
conn = get(conn, ~p"/api/v1/account/data")
[disposition] = get_resp_header(conn, "content-disposition")
assert disposition =~ "attachment"
assert disposition =~ "towerops-data-#{user.id}"
assert disposition =~ ".json"
end
test "includes profile data in response", %{conn: conn, user: user} do
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert data["profile"]["id"] == user.id
assert data["profile"]["email"] == user.email
assert data["profile"]["name"] == user.name
# Default timezone is "UTC" even if user record has nil
assert data["profile"]["timezone"] in ["UTC", user.timezone]
assert data["profile"]["is_superuser"] == user.is_superuser
end
test "includes empty credentials when user has no WebAuthn credentials", %{conn: conn} do
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert data["credentials"] == []
end
test "includes credentials when user has WebAuthn credentials", %{conn: conn, user: user} do
# Create a WebAuthn credential using the fixture
credential = credential_fixture(user, %{name: "Test Key"})
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["credentials"]) == 1
[cred] = data["credentials"]
assert cred["id"] == credential.id
assert cred["name"] == "Test Key"
assert is_binary(cred["public_key"])
end
test "includes organizations data", %{conn: conn, user: user} do
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["organizations"]) == 1
[org_data] = data["organizations"]
assert org_data["id"] == org.id
assert org_data["name"] == "Test Org"
assert org_data["role"] == "owner"
end
test "includes devices data with redacted SNMP community", %{conn: conn, user: user} do
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_community: "secret-community-string"
})
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["devices"]) == 1
[device_data] = data["devices"]
assert device_data["id"] == device.id
assert device_data["name"] == "Test Router"
assert device_data["ip_address"] == "192.168.1.1"
# SNMP community should be redacted for security
assert device_data["snmp_community"] == "[REDACTED]"
assert device_data["organization"]["name"] == "Test Org"
assert device_data["site"]["name"] == "Test Site"
end
test "returns empty devices when user has no organizations", %{conn: conn} do
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert data["devices"] == []
end
test "includes alerts data from last 90 days", %{conn: conn, user: user} do
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id
})
# Create a recent alert
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device down"
})
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["alerts"]) == 1
[alert_data] = data["alerts"]
assert alert_data["id"] == alert.id
assert alert_data["alert_type"] == "device_down"
assert alert_data["message"] == "Device down"
assert alert_data["device"]["name"] == "Test Router"
end
test "includes all alerts regardless of age (filtered by inserted_at, not triggered_at)", %{
conn: conn,
user: user
} do
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, device} =
Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id
})
# Create an alert with an old triggered_at (120 days ago)
# Note: The API filters by inserted_at (when record was created), not triggered_at
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.add(DateTime.utc_now(), -120, :day),
message: "Old triggered time but recent record"
})
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
# Alert should be included because it was recently inserted (even though triggered long ago)
assert length(data["alerts"]) == 1
assert hd(data["alerts"])["id"] == alert.id
end
test "returns empty alerts when user has no organizations", %{conn: conn} do
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert data["alerts"] == []
end
test "includes audit logs data", %{conn: conn, user: user} do
# Create superuser for audit log
superuser = user_fixture(%{superuser: true})
{:ok, _log} =
Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: user.id,
ip_address: "127.0.0.1"
})
# Sleep briefly to ensure different timestamps
Process.sleep(50)
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["audit_logs"]) == 2
# Find the logs by action (ordering might vary slightly)
export_log = Enum.find(data["audit_logs"], &(&1["action"] == "user_data_exported"))
impersonate_log = Enum.find(data["audit_logs"], &(&1["action"] == "impersonate_start"))
# Verify export log
assert export_log
assert export_log["actor_email"] == user.email
# Verify impersonation log
assert impersonate_log
assert impersonate_log["target_user_id"] == user.id
assert impersonate_log["actor_email"] == superuser.email
end
test "includes export metadata", %{conn: conn} do
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert data["export_info"]["format"] == "JSON"
assert data["export_info"]["gdpr_article"] == "Article 15 - Right to Access"
assert is_binary(data["export_info"]["exported_at"])
end
test "creates audit log entry for data export", %{conn: conn, user: user} do
# Count existing audit logs
initial_count = user.id |> Admin.list_audit_logs_for_user() |> length()
conn = get(conn, ~p"/api/v1/account/data")
assert response(conn, 200)
# Should have one more audit log
logs = Admin.list_audit_logs_for_user(user.id)
assert length(logs) == initial_count + 1
# Most recent log should be the data export
[latest_log | _] = logs
assert latest_log.action == "user_data_exported"
assert latest_log.superuser_id == user.id
end
test "handles user with multiple organizations", %{conn: conn, user: user} do
{:ok, _org1} = Organizations.create_organization(%{name: "Org 1"}, user.id)
# Create second org by adding membership instead of creating new one
# (to avoid subscription limit issues in tests)
other_user = user_fixture()
{:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, other_user.id)
{:ok, _membership} =
Organizations.create_membership(%{
organization_id: org2.id,
user_id: user.id,
role: :member
})
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["organizations"]) == 2
org_names = data["organizations"] |> Enum.map(& &1["name"]) |> Enum.sort()
assert org_names == ["Org 1", "Org 2"]
end
test "handles user as member (not owner) of organization", %{conn: conn, user: user} do
# Create another user who owns the org
owner = user_fixture()
{:ok, org} = Organizations.create_organization(%{name: "Owner's Org"}, owner.id)
# Add current user as member
{:ok, _membership} =
Organizations.create_membership(%{
organization_id: org.id,
user_id: user.id,
role: :member
})
conn = get(conn, ~p"/api/v1/account/data")
data = json_response(conn, 200)
assert length(data["organizations"]) == 1
[org_data] = data["organizations"]
assert org_data["name"] == "Owner's Org"
assert org_data["role"] == "member"
end
end
end