Security hardening + performance fixes across codebase

CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
This commit is contained in:
Graham McIntire 2026-06-21 17:40:50 -05:00
parent 89ed385865
commit 3a408a8dc1
86 changed files with 671 additions and 197 deletions

View file

@ -142,6 +142,17 @@ defmodule Towerops.Admin do
Repo.aggregate(Organization, :count)
end
@doc """
Returns the count of organizations with active subscriptions.
"""
def count_active_subscriptions do
Repo.one(
from o in Organization,
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
select: count(o.id)
)
end
@doc """
Deletes an organization and creates an audit log entry.

View file

@ -293,16 +293,31 @@ defmodule Towerops.Alerts do
end
@doc """
Gets a single alert.
where: s.organization_id == ^organization_id,
order_by: [desc: a.triggered_at],
limit: ^limit,
preload: [device: {e, site: s}, acknowledged_by: []]
)
)
Gets a single alert and verifies it belongs to the given organization.
Preloads device, site, organization, and acknowledged_by for access checking.
Returns `{:ok, alert}` if found and belongs to the organization,
`{:error, :not_found}` if not found, or `{:error, :unauthorized}` if the
alert's device does not belong to the organization.
"""
@spec get_verified_alert(binary(), binary()) ::
{:ok, Alert.t()} | {:error, :not_found | :unauthorized}
def get_verified_alert(alert_id, organization_id) do
alert =
Alert
|> Repo.get(alert_id)
|> case do
nil -> nil
alert -> Repo.preload(alert, [:acknowledged_by, device: [site: :organization]])
end
case alert do
%Alert{device: %{site: %{organization_id: ^organization_id}}} -> {:ok, alert}
nil -> {:error, :not_found}
%Alert{} -> {:error, :unauthorized}
end
end
@doc \"""
@doc """
Gets a single alert.
"""
def get_alert!(id) do

View file

@ -21,15 +21,20 @@ defmodule Towerops.ApiTokens do
"""
@spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
def create_api_token(attrs \\ %{}) do
# Extract required fields from attrs map
org_id = Map.get(attrs, :organization_id) || Map.get(attrs, "organization_id")
user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id")
# Generate a random token
raw_token = generate_token()
token_hash = hash_token(raw_token)
attrs = Map.put(attrs, :token_hash, token_hash)
token_attrs = Map.take(attrs, [:name, :expires_at])
token_attrs = Map.put(token_attrs, :token_hash, token_hash)
with :ok <- validate_membership(attrs[:organization_id], attrs[:user_id]) do
with :ok <- validate_membership(org_id, user_id) do
case %ApiToken{}
|> ApiToken.changeset(attrs)
|> ApiToken.create_changeset(org_id, user_id, token_attrs)
|> Repo.insert() do
{:ok, api_token} ->
{:ok, {api_token, raw_token}}
@ -131,7 +136,7 @@ defmodule Towerops.ApiTokens do
@spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
def update_api_token(%ApiToken{} = api_token, attrs) do
api_token
|> ApiToken.changeset(attrs)
|> ApiToken.update_changeset(attrs)
|> Repo.update()
end

View file

@ -24,14 +24,31 @@ defmodule Towerops.ApiTokens.ApiToken do
timestamps(type: :utc_datetime)
end
@doc false
def changeset(api_token, attrs) do
@doc """
Creates an API token changeset with programmatically-set fields.
`:organization_id` and `:user_id` are set explicitly, not cast from user input.
This prevents creating tokens for unauthorized organizations or users.
"""
def create_changeset(api_token, organization_id, user_id, attrs) do
api_token
|> cast(attrs, [:organization_id, :user_id, :name, :token_hash, :expires_at, :last_used_at])
|> cast(attrs, [:name, :token_hash, :expires_at, :last_used_at])
|> put_change(:organization_id, organization_id)
|> put_change(:user_id, user_id)
|> validate_required([:organization_id, :name, :token_hash])
|> validate_length(:name, min: 1, max: 255)
|> unique_constraint(:token_hash)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:user_id)
end
@doc """
Changeset for updating an API token. Only allows changing `:name` and `:expires_at`.
"""
def update_changeset(api_token, attrs) do
api_token
|> cast(attrs, [:name, :expires_at, :last_used_at])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 255)
end
end

View file

@ -47,6 +47,41 @@ defmodule Towerops.Capacity do
}
end
@doc """
Batch calculates utilization for multiple interfaces in a single query.
Returns a map of `%{interface_id => %{utilization_pct: float, in_pct: float, out_pct: float, throughput: map}}`.
Interfaces without a configured capacity (nil or <= 0) are excluded from the result.
"""
def get_utilization_for_interfaces(interfaces) do
interfaces_with_capacity =
Enum.filter(interfaces, fn intf ->
cap = intf.configured_capacity_bps
is_integer(cap) and cap > 0
end)
if interfaces_with_capacity == [] do
%{}
else
ids = Enum.map(interfaces_with_capacity, & &1.id)
since = DateTime.add(DateTime.utc_now(), -900, :second)
throughput_by_id = batch_calculate_throughput(ids, since)
Map.new(interfaces_with_capacity, fn intf ->
throughput = Map.get(throughput_by_id, intf.id, zero_throughput())
cap = intf.configured_capacity_bps
{intf.id,
%{
utilization_pct: throughput.max_bps / cap * 100,
in_pct: throughput.in_bps / cap * 100,
out_pct: throughput.out_bps / cap * 100,
throughput: throughput
}}
end)
end
end
@doc """
Returns a capacity summary for all interfaces at a site.
"""

View file

@ -160,6 +160,19 @@ defmodule Towerops.Coverages do
|> Repo.one!()
end
@doc """
Fetches a coverage scoped to an organization with device and site preloaded.
Raises if not found.
"""
@spec get_coverage_with_device!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
def get_coverage_with_device!(organization_id, id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(id: ^id)
|> preload([:device, :site])
|> Repo.one!()
end
@doc "Fetches a coverage scoped to an organization. Returns nil if not found."
@spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil
def get_coverage(organization_id, id) do

View file

@ -34,6 +34,7 @@ defmodule Towerops.Devices do
site_id
|> DeviceQuery.for_site()
|> DeviceQuery.order_by_display()
|> preload([:site, :organization])
|> Repo.all()
end
@ -68,7 +69,7 @@ defmodule Towerops.Devices do
join: o in assoc(e, :organization),
where: e.organization_id in ^organization_ids,
order_by: [asc: e.organization_id, asc: e.name],
preload: [site: s, organization: o]
preload: [organization: o, site: {s, :organization}]
)
)
end

View file

@ -26,6 +26,7 @@ defmodule Towerops.OnCall do
|> ScheduleQuery.for_organization()
|> ScheduleQuery.order_by_name()
|> Repo.all()
|> Repo.preload(overrides: :user, layers: [members: :user])
end
def get_schedule!(id) do

View file

@ -107,12 +107,9 @@ defmodule Towerops.Organizations do
has_existing = Repo.exists?(from m in Membership, where: m.user_id == ^user_id),
{:ok, organization} <- Repo.insert(Organization.changeset(%Organization{}, attrs)),
membership_changeset =
Membership.changeset(%Membership{}, %{
organization_id: organization.id,
user_id: user_id,
role: :owner,
is_default: !has_existing
}),
%Membership{}
|> Membership.create_changeset(organization.id, user_id, :owner)
|> Ecto.Changeset.put_change(:is_default, !has_existing),
{:ok, _membership} <- Repo.insert(membership_changeset) do
organization
else
@ -267,10 +264,28 @@ defmodule Towerops.Organizations do
Updates a member's role. Owners cannot have their role changed.
"""
def update_member_role(organization_id, user_id, new_role) do
case get_membership(organization_id, user_id) do
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
%Membership{} = membership -> update_membership(membership, %{role: new_role})
nil -> {:error, :not_found}
role_atom = parse_role(new_role)
if role_atom == :invalid do
changeset =
%Membership{}
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(:role, "is invalid")
{:error, changeset}
else
case get_membership(organization_id, user_id) do
%Membership{role: :owner} ->
{:error, :cannot_change_owner_role}
%Membership{} = membership ->
membership
|> Membership.role_update_changeset(role_atom)
|> Repo.update()
nil ->
{:error, :not_found}
end
end
end
@ -290,15 +305,35 @@ defmodule Towerops.Organizations do
@doc """
Creates a membership (adds a user to an organization).
Expects a map with `:organization_id`, `:user_id`, and `:role` keys.
These fields are set programmatically, not cast from user input.
"""
def create_membership(attrs) do
org_id = Map.get(attrs, :organization_id) || Map.get(attrs, "organization_id")
user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id")
role = parse_role(Map.get(attrs, :role) || Map.get(attrs, "role") || :member)
%Membership{}
|> Membership.changeset(attrs)
|> Membership.create_changeset(org_id, user_id, role)
|> Repo.insert()
end
defp parse_role(role) when is_atom(role), do: role
defp parse_role(role) when is_binary(role) do
String.to_existing_atom(role)
rescue
ArgumentError -> :invalid
end
defp parse_role(_), do: :invalid
@doc """
Updates a membership (changes a user's role).
Updates non-security fields of a membership.
Currently only allows changing `:is_default`. Use `update_member_role/3`
for role changes (which includes authorization checks).
"""
def update_membership(%Membership{} = membership, attrs) do
membership
@ -428,11 +463,12 @@ defmodule Towerops.Organizations do
})
membership_changeset =
Membership.changeset(%Membership{}, %{
organization_id: invitation.organization_id,
user_id: user_id,
role: invitation.role
})
Membership.create_changeset(
%Membership{},
invitation.organization_id,
user_id,
invitation.role
)
with {:ok, _} <- Repo.update(invitation_changeset),
{:ok, membership} <- Repo.insert(membership_changeset) do

View file

@ -39,10 +39,19 @@ defmodule Towerops.Organizations.Membership do
updated_at: DateTime.t()
}
@doc false
def changeset(membership, attrs) do
@doc """
Creates a membership changeset with programmatically-set fields.
`:organization_id`, `:user_id`, and `:role` are set explicitly, not cast from user input.
This prevents privilege escalation via mass assignment.
"""
def create_changeset(membership, organization_id, user_id, role) do
membership
|> cast(attrs, [:role, :organization_id, :user_id, :is_default])
|> cast(%{}, [])
|> put_change(:organization_id, organization_id)
|> put_change(:user_id, user_id)
|> put_change(:role, role)
|> put_change(:is_default, false)
|> validate_required([:role, :organization_id, :user_id])
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:user_id)
@ -50,4 +59,28 @@ defmodule Towerops.Organizations.Membership do
name: :organization_memberships_organization_id_user_id_index
)
end
@doc """
Changeset for updating a membership role.
Only `:role` can be changed. Other fields are immutable after creation.
Roles are validated against the enum type.
"""
def role_update_changeset(membership, new_role) do
membership
|> cast(%{}, [])
|> put_change(:role, new_role)
|> validate_required([:role])
end
@doc """
Changeset for updating non-security fields of a membership.
Currently only allows changing `:is_default`. Does NOT allow changing `:role`,
`:organization_id`, or `:user_id` use `role_update_changeset/2` or
`create_changeset/4` for those respectively.
"""
def changeset(membership, attrs) do
cast(membership, attrs, [:is_default])
end
end

View file

@ -257,6 +257,14 @@ defmodule Towerops.Snmp do
defdelegate set_manual_capacity(interface_id, capacity_bps), to: Interfaces
defdelegate clear_manual_capacity(interface_id), to: Interfaces
@doc """
Checks whether an SNMP interface belongs to a device in the given organization.
Returns boolean.
"""
def interface_belongs_to_organization?(interface_id, org_id) do
Interfaces.belongs_to_organization?(interface_id, org_id)
end
## Sensor metadata delegates (implementation in `Towerops.Snmp.Sensors`).
defdelegate list_sensors(device_id), to: Sensors

View file

@ -78,4 +78,19 @@ defmodule Towerops.Snmp.Interfaces do
|> Repo.update()
end
end
@doc """
Checks whether an SNMP interface belongs to a device in the given organization.
Returns boolean.
"""
def belongs_to_organization?(interface_id, org_id) do
query =
from i in Interface,
join: sd in assoc(i, :snmp_device),
join: d in assoc(sd, :device),
where: i.id == ^interface_id and d.organization_id == ^org_id,
select: count(i.id)
Repo.one(query) > 0
end
end

View file

@ -121,10 +121,6 @@ defmodule ToweropsWeb.Layouts do
def authenticated(assigns) do
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
# Get unresolved alert count from process dictionary (set/updated in on_mount hook)
unresolved_alert_count = Process.get(:unresolved_alert_count, 0)
assigns = Map.put(assigns, :unresolved_alert_count, unresolved_alert_count)
# Extract organization and timezone from scope for template convenience
current_organization = assigns[:current_scope] && assigns.current_scope.organization
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"

View file

@ -21,25 +21,31 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do
organization_id = conn.assigns.current_organization_id
user = conn.assigns[:current_user]
attrs = %{
organization_id: organization_id,
email: email,
role: role,
invited_by_id: user && user.id,
token: Ecto.UUID.generate(),
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second)
}
case authorize_invite(organization_id, user, role) do
{:ok, _} ->
attrs = %{
organization_id: organization_id,
email: email,
role: role,
invited_by_id: user.id,
token: Ecto.UUID.generate(),
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second)
}
case Organizations.create_invitation(attrs) do
{:ok, invitation} ->
conn
|> put_status(:created)
|> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))})
case Organizations.create_invitation(attrs) do
{:ok, invitation} ->
conn
|> put_status(:created)
|> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))})
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, reason} ->
conn |> put_status(:forbidden) |> json(%{error: reason})
end
end
@ -47,6 +53,23 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do
conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"})
end
defp authorize_invite(_org_id, nil, _role), do: {:error, "authentication required"}
defp authorize_invite(org_id, user, _role) do
membership = Organizations.get_membership(org_id, user.id)
case membership do
nil ->
{:error, "not a member of this organization"}
%{role: member_role} when member_role not in [:owner, :admin] ->
{:error, "only admins and owners can send invitations"}
_ ->
{:ok, membership}
end
end
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id

View file

@ -222,6 +222,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
{content.()}
</Layouts.authenticated>

View file

@ -396,6 +396,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
{content.()}
</Layouts.authenticated>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="text-center">
<.header>
{t_auth("Account Settings")}

View file

@ -7,6 +7,11 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
alias ToweropsWeb.GraphQL.Resolvers.Helpers
alias ToweropsWeb.ScopedResource
# Roles that are allowed to send invitations
@invite_roles [:owner, :admin]
# Role hierarchy for checking if inviter can assign a given role
@role_rank %{owner: 0, admin: 1, executive: 2, technician: 3, member: 4, viewer: 5}
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
members = Organizations.list_organization_members(org_id)
{:ok, members}
@ -17,19 +22,56 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
role = Map.get(args, :role, "technician")
case Organizations.create_invitation(%{
email: email,
role: role,
organization_id: org_id,
invited_by_id: user.id
}) do
{:ok, invitation} -> {:ok, invitation}
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
with {:ok, _membership} <- authorize_invite(org_id, user.id, role) do
case Organizations.create_invitation(%{
email: email,
role: role,
organization_id: org_id,
invited_by_id: user.id
}) do
{:ok, invitation} -> {:ok, invitation}
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
end
end
end
def invite(_parent, _args, _resolution), do: Helpers.authentication_error()
defp authorize_invite(org_id, user_id, requested_role) do
case Organizations.get_membership(org_id, user_id) do
nil ->
{:error, "not a member of this organization"}
membership when membership.role not in @invite_roles ->
{:error, "only admins and owners can send invitations"}
membership ->
authorize_role(membership, requested_role)
end
end
defp authorize_role(membership, requested_role) do
case parse_role(requested_role) do
nil ->
{:error, "invalid role: #{requested_role}"}
requested_role_atom ->
if @role_rank[membership.role] <= @role_rank[requested_role_atom] do
{:ok, membership}
else
{:error, "cannot assign a role higher than your own"}
end
end
end
defp parse_role(role) when is_binary(role) do
atom = String.to_existing_atom(role)
if Map.has_key?(@role_rank, atom), do: atom
rescue
ArgumentError -> nil
end
def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
with {:ok, invitation} <- fetch_org_invitation(id, org_id) do
case Repo.delete(invitation) do

View file

@ -23,7 +23,12 @@ defmodule ToweropsWeb.AccountLive.Activity do
@impl true
def render(assigns) do
~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="settings">
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Activity Log

View file

@ -66,6 +66,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
flash={@flash}
current_scope={@current_scope}
active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
@ -628,9 +629,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
if Enum.empty?(org_ids) do
[]
else
org_ids
|> Devices.list_devices_for_organizations()
|> Towerops.Repo.preload(site: :organization)
Devices.list_devices_for_organizations(org_ids)
end
end
@ -646,9 +645,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
else
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
org_ids
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|> Towerops.Repo.preload(:device)
Alerts.list_alerts_for_organizations(org_ids, since: ninety_days_ago)
end
end

View file

@ -135,7 +135,11 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
@impl true
def render(assigns) do
~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mx-auto max-w-2xl px-4 py-12">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
<%= if @show_recovery_codes do %>

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="activity"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
<div class="flex items-center gap-3">

View file

@ -4,12 +4,9 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
"""
use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.Admin
alias Towerops.Billing
alias Towerops.Organizations.Organization
alias Towerops.Repo
on_mount {ToweropsWeb.UserAuth, :require_superuser}
@ -18,7 +15,7 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
orgs = Admin.list_all_organizations()
default_free_devices = Billing.default_free_devices()
default_price = Billing.default_price_per_device()
active_sub_count = count_active_subscriptions()
active_sub_count = Admin.count_active_subscriptions()
ip =
case get_connect_info(socket, :peer_data) do
@ -261,12 +258,4 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
end
end
defp count_active_subscriptions do
Repo.one(
from o in Organization,
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
select: count(o.id)
)
end
end

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="agents"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="agents"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="alerts"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<%!-- Compact header --%>
<div class="flex items-center justify-between mb-1">

View file

@ -11,7 +11,12 @@ defmodule ToweropsWeb.ChangelogLive do
@impl true
def render(assigns) do
~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="changelog">
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="changelog"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mx-auto max-w-3xl px-4 py-8">
<h1 class="text-3xl font-bold mb-2">What's New</h1>
<p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p>

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -10,7 +10,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_scope.organization
coverage = organization.id |> Coverages.get_coverage!(id) |> Towerops.Repo.preload([:device, :site])
coverage = Coverages.get_coverage_with_device!(organization.id, id)
antenna = Antenna.get(coverage.antenna_slug)
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
@ -95,9 +95,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
@impl true
def handle_info({:coverage_status, _status, _extra}, socket) do
coverage =
socket.assigns.organization.id
|> Coverages.get_coverage!(socket.assigns.coverage.id)
|> Towerops.Repo.preload([:device, :site])
Coverages.get_coverage_with_device!(socket.assigns.organization.id, socket.assigns.coverage.id)
eirp =
Coverage.eirp_dbm(

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<%!-- Top bar: title, status, actions. Compact so the map can dominate. --%>
<div class="mb-4 flex items-start justify-between gap-4">

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="dashboard"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div
id="dynamic-favicon"

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore">
<div class="border-b border-gray-200 pb-5 dark:border-white/5">

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="devices"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<%!-- Header: title + actions, then tabs below on mobile --%>
<div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">

View file

@ -511,13 +511,12 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp assign_ports_data(socket) do
interfaces = socket.assigns.snmp_interfaces
# Batch query utilization for all interfaces in a single query (fixes N+1)
utilization_by_id = Towerops.Capacity.get_utilization_for_interfaces(interfaces)
interfaces_with_utilization =
Enum.map(interfaces, fn interface ->
utilization =
if interface.configured_capacity_bps do
Towerops.Capacity.get_utilization(interface)
end
utilization = Map.get(utilization_by_id, interface.id)
Map.put(interface, :utilization, utilization)
end)
@ -905,22 +904,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
end
defp interface_belongs_to_org?(interface_id, org_id) do
{:ok, iid} = Ecto.UUID.dump(interface_id)
{:ok, oid} = Ecto.UUID.dump(org_id)
result =
Towerops.Repo.query!(
"""
SELECT 1 FROM snmp_interfaces i
JOIN snmp_devices sd ON sd.id = i.snmp_device_id
JOIN devices d ON d.id = sd.device_id
WHERE i.id = $1 AND d.organization_id = $2
LIMIT 1
""",
[iid, oid]
)
result.num_rows > 0
Snmp.interface_belongs_to_organization?(interface_id, org_id)
end
defp reload_snmp_and_ports(socket) do

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="devices"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mb-6 -mt-2">
<.breadcrumb items={

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-center gap-3 mb-6">
<.link

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="devices"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mb-6">
<!-- Header with back button -->

View file

@ -3,6 +3,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="help"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.help_content
active_section={@active_section}

View file

@ -6,10 +6,9 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do
(devices, sites, alerts) within their organization scope.
"""
alias Towerops.Alerts.Alert
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Sites.Site
@ -52,13 +51,6 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do
@spec verify_alert_access(binary(), binary()) ::
{:ok, Alert.t()} | {:error, :not_found | :unauthorized}
def verify_alert_access(alert_id, organization_id) do
with %Alert{} = alert <- Repo.get(Alert, alert_id),
alert = Repo.preload(alert, [:acknowledged_by, device: [site: :organization]]),
%Alert{device: %{site: %{organization_id: ^organization_id}}} <- alert do
{:ok, alert}
else
nil -> {:error, :not_found}
%Alert{} -> {:error, :unauthorized}
end
Alerts.get_verified_alert(alert_id, organization_id)
end
end

View file

@ -6,7 +6,6 @@ defmodule ToweropsWeb.InsightsLive.Index do
alias Towerops.Accounts.Scope
alias Towerops.Preseem
alias Towerops.Repo
require Logger
@ -177,10 +176,7 @@ defmodule ToweropsWeb.InsightsLive.Index do
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
else: opts
insights =
org_id
|> Preseem.list_insights(opts)
|> Repo.preload([:preseem_access_point, :device])
insights = Preseem.list_insights(org_id, opts)
assign(socket, :insights, insights)
end

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="insights"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5">
<div>

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="maintenance"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mb-6">
<.link

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="maintenance"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-center justify-between mb-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Maintenance Windows")}</h1>

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="maintenance"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mb-6">
<.link

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="sites-map"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
<span class="flex items-center gap-2">

View file

@ -5,7 +5,6 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
alias Towerops.Devices
alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.MikrotikBackups.Differ
alias Towerops.Repo
@impl true
def mount(_params, _session, socket) do
@ -25,9 +24,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|> push_navigate(to: ~p"/devices")}
device ->
device_with_site = Repo.preload(device, site: :organization)
if device_with_site.site.organization_id == organization.id do
if device.site.organization_id == organization.id do
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
else
{:noreply,

View file

@ -118,7 +118,11 @@ defmodule ToweropsWeb.MobileQRLive do
@impl true
def render(assigns) do
~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mx-auto max-w-4xl">
<.header>
Link Mobile App

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="network-map"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
<span class="flex items-center gap-2">

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="max-w-2xl mx-auto py-8">
<%!-- Step indicator --%>

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.breadcrumb items={[

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.breadcrumb items={[

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.breadcrumb items={[

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="pb-5">
<.breadcrumb items={[

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="reports"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="rf-links"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-center gap-3 mb-6">
<.link

View file

@ -136,10 +136,7 @@ defmodule ToweropsWeb.ScheduleLive.Index do
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
preloaded_schedules =
Towerops.Repo.preload(schedules, overrides: :user, layers: [members: :user])
Enum.map(preloaded_schedules, fn preloaded ->
Enum.map(schedules, fn preloaded ->
schedule = preloaded
on_call = Resolver.resolve(preloaded, DateTime.utc_now())

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex items-center justify-between mb-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1>

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="flex flex-wrap items-start justify-between gap-3 mb-6">
<div class="flex items-center gap-3 min-w-0">

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mb-4">
<.link

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="sites"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="sites"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -42,6 +42,48 @@ defmodule ToweropsWeb.StatusPageLive do
end
end
@impl true
def handle_params(params, _url, socket) do
slug = Map.get(params, "slug", "")
case slug do
"" ->
{:noreply,
socket
|> put_flash(:error, "Status page not found")
|> assign(:not_found, true)
|> assign(:page_title, "Not Found")}
slug ->
case StatusPages.get_config_by_slug(slug) do
nil ->
{:noreply,
socket
|> put_flash(:error, "Status page not found")
|> assign(:not_found, true)
|> assign(:page_title, "Not Found")}
config ->
maybe_subscribe_to_status_page(socket, config)
components = StatusPages.list_components(config.id)
active_incidents = StatusPages.list_active_incidents(config.id)
recent_incidents = StatusPages.list_incidents(config.id, limit: 10)
overall = StatusPages.overall_status(components)
{:noreply,
socket
|> assign(:config, config)
|> assign(:components, components)
|> assign(:active_incidents, active_incidents)
|> assign(:recent_incidents, recent_incidents)
|> assign(:overall_status, overall)
|> assign(:not_found, false)
|> assign(:page_title, "#{config.company_name || "Status"} — System Status")}
end
end
end
@impl true
def handle_info({:status_updated, _}, socket) do
config = socket.assigns.config
@ -104,4 +146,10 @@ defmodule ToweropsWeb.StatusPageLive do
def incident_status_label("monitoring"), do: "Monitoring"
def incident_status_label("resolved"), do: "Resolved"
def incident_status_label(s), do: s
defp maybe_subscribe_to_status_page(socket, config) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "status_page:#{config.id}")
end
end
end

View file

@ -2,6 +2,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="trace"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++

View file

@ -10,6 +10,7 @@ defmodule ToweropsWeb.UserRegistrationLive do
alias Towerops.Organizations
alias Towerops.Workers.WelcomeEmailWorker
@impl true
def mount(params, session, socket) do
# Check if registering via invitation
invitation_token = params["invitation_token"] || params["token"]
@ -32,6 +33,18 @@ defmodule ToweropsWeb.UserRegistrationLive do
|> assign(:terms_of_service_consent, false)}
end
@impl true
def handle_params(params, _url, socket) do
invitation_token = Map.get(params, "invitation_token") || Map.get(params, "token")
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
{:noreply,
socket
|> assign(:invitation, invitation)
|> assign(:invitation_token, invitation_token)}
end
@impl true
def handle_event("check_password_breach", %{"value" => password}, socket)
when is_binary(password) and byte_size(password) > 0 do
case HIBP.check_password(password) do
@ -151,6 +164,7 @@ defmodule ToweropsWeb.UserRegistrationLive do
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>

View file

@ -7,6 +7,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do
alias Towerops.Accounts
alias Towerops.Accounts.HIBP
@impl true
def mount(%{"token" => token}, _session, socket) do
case Accounts.get_user_by_reset_password_token(token) do
nil ->
@ -27,6 +28,37 @@ defmodule ToweropsWeb.UserResetPasswordLive do
end
end
@impl true
def handle_params(params, _url, socket) do
case Map.get(params, "token", "") do
"" ->
{:noreply,
socket
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|> redirect(to: ~p"/users/log-in")}
token ->
case Accounts.get_user_by_reset_password_token(token) do
nil ->
{:noreply,
socket
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|> redirect(to: ~p"/users/log-in")}
user ->
changeset = Accounts.change_user_password(user)
{:noreply,
socket
|> assign(:page_title, t("Reset Password"))
|> assign(:form, to_form(changeset))
|> assign(:user, user)
|> assign(:password_breach_count, nil)}
end
end
end
@impl true
def handle_event("check_password_breach", %{"value" => password}, socket)
when is_binary(password) and byte_size(password) > 0 do
case HIBP.check_password(password) do
@ -64,6 +96,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>

View file

@ -1,6 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="pb-5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">

View file

@ -7,6 +7,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="weathermap"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
<span class="flex items-center gap-2">

View file

@ -607,10 +607,9 @@ defmodule ToweropsWeb.UserAuth do
alert_count =
if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0
Process.put(:unresolved_alert_count, alert_count)
socket =
socket
|> Phoenix.Component.assign(:unresolved_alert_count, alert_count)
|> maybe_subscribe_to_status_updates(organization)
|> attach_status_title_hook(organization)
|> attach_alert_count_hook(organization)
@ -1079,8 +1078,11 @@ defmodule ToweropsWeb.UserAuth do
LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn
{:alert_changed, org_id}, socket when org_id == organization.id ->
count = Towerops.Alerts.count_active_alerts(organization.id)
Process.put(:unresolved_alert_count, count)
{:cont, Phoenix.Component.assign(socket, :_alert_count_tick, count)}
{:cont,
socket
|> Phoenix.Component.assign(:_alert_count_tick, count)
|> Phoenix.Component.assign(:unresolved_alert_count, count)}
_message, socket ->
{:cont, socket}

View file

@ -216,8 +216,9 @@ defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
# Circuit should be open
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Wait for recovery timeout
Process.sleep(110)
# Wait for recovery timeout using a message-based timer
Process.send_after(self(), :recovery_elapsed, 110)
assert_receive :recovery_elapsed, 1000
# Should allow limited calls in half-open state
success_fun = fn -> {:ok, :recovered} end

View file

@ -6,7 +6,7 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
alias Towerops.ApiTokens.ApiToken
describe "changeset/2" do
describe "create_changeset/4 and update_changeset/2" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
@ -18,136 +18,124 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
organization: organization
} do
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
test "valid changeset without user_id", %{organization: organization} do
test "valid changeset without user_id", %{organization: organization, user: _user} do
attrs = %{
organization_id: organization.id,
name: "Test Token",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, nil, attrs)
assert changeset.valid?
end
test "valid changeset with expires_at", %{organization: organization} do
test "valid changeset with expires_at", %{organization: organization, user: user} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
attrs = %{
organization_id: organization.id,
name: "Test Token",
token_hash: "abc123",
expires_at: expires_at
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
test "valid changeset with last_used_at", %{organization: organization} do
test "valid changeset with last_used_at", %{organization: organization, user: user} do
last_used_at = DateTime.utc_now()
attrs = %{
organization_id: organization.id,
name: "Test Token",
token_hash: "abc123",
last_used_at: last_used_at
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
test "invalid changeset without organization_id", %{user: user} do
attrs = %{
user_id: user.id,
name: "Test Token",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, nil, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "invalid changeset without name", %{organization: organization} do
test "invalid changeset without name", %{organization: organization, user: user} do
attrs = %{
organization_id: organization.id,
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "invalid changeset without token_hash", %{organization: organization} do
test "invalid changeset without token_hash", %{organization: organization, user: user} do
attrs = %{
organization_id: organization.id,
name: "Test Token"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).token_hash
end
test "invalid changeset with blank name", %{organization: organization} do
test "invalid changeset with blank name", %{organization: organization, user: user} do
attrs = %{
organization_id: organization.id,
name: "",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "invalid changeset with name too short", %{organization: organization} do
test "invalid changeset with name too short", %{organization: organization, user: user} do
attrs = %{
organization_id: organization.id,
name: "",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
end
test "invalid changeset with name too long", %{organization: organization} do
test "invalid changeset with name too long", %{organization: organization, user: user} do
long_name = String.duplicate("a", 256)
attrs = %{
organization_id: organization.id,
name: long_name,
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "should be at most 255 character(s)" in errors_on(changeset).name
end
test "changeset with maximum valid name length", %{organization: organization} do
test "changeset with maximum valid name length", %{organization: organization, user: user} do
max_name = String.duplicate("a", 255)
attrs = %{
organization_id: organization.id,
name: max_name,
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
end

View file

@ -264,6 +264,90 @@ defmodule Towerops.CapacityTest do
end
end
describe "get_utilization_for_interfaces/1" do
test "returns empty map when given empty list" do
assert Capacity.get_utilization_for_interfaces([]) == %{}
end
test "calculates utilization for multiple interfaces in one batch", %{
snmp_device: snmp_device,
interface: interface
} do
now = DateTime.utc_now()
insert_interface_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_interface_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 1_125_000_000,
checked_at: DateTime.add(now, -60, :second)
})
# Second interface
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1",
if_speed: 1_000_000_000,
if_type: 6,
configured_capacity_bps: 500_000_000,
capacity_source: "manual"
})
|> Repo.insert!()
insert_interface_stat(interface2.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
# 500 Mbps capacity, sending at ~250 Mbps = 50% utilization
insert_interface_stat(interface2.id, %{
if_in_octets: 0,
if_out_octets: 1_875_000_000,
checked_at: DateTime.add(now, -60, :second)
})
result = Capacity.get_utilization_for_interfaces([interface, interface2])
assert %{utilization_pct: pct1} = result[interface.id]
assert_in_delta pct1, 50.0, 1.0
assert %{utilization_pct: pct2} = result[interface2.id]
assert_in_delta pct2, 50.0, 1.0
end
test "excludes interfaces without configured capacity", %{
snmp_device: snmp_device,
interface: interface
} do
no_cap_interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 3,
if_name: "no-cap"
})
|> Repo.insert!()
result = Capacity.get_utilization_for_interfaces([no_cap_interface, interface])
refute Map.has_key?(result, no_cap_interface.id)
assert Map.has_key?(result, interface.id)
end
test "returns empty map when no interfaces have capacity" do
assert Capacity.get_utilization_for_interfaces([%{id: "1", configured_capacity_bps: nil}]) == %{}
assert Capacity.get_utilization_for_interfaces([%{id: "2", configured_capacity_bps: 0}]) == %{}
end
end
describe "get_site_capacity_summary/1" do
test "aggregates capacity across interfaces at a site", %{
site: site,

View file

@ -41,11 +41,18 @@ defmodule Towerops.EquipmentTest do
}
@invalid_attrs %{name: nil, ip_address: nil}
test "list_site_devices/1 returns all devices for a site", %{organization: organization, site: site} do
test "list_site_devices/1 returns all devices for a site with preloaded associations", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert Devices.list_site_devices(site.id) == [device]
result = Devices.list_site_devices(site.id)
assert length(result) == 1
assert hd(result).id == device.id
assert hd(result).site.id == site.id
assert hd(result).organization.id == organization.id
end
test "list_organization_devices/1 returns all devices for an organization", %{

View file

@ -191,10 +191,21 @@ defmodule Towerops.OrganizationsTest do
assert "can't be blank" in errors_on(changeset).organization_id
end
test "update_membership/2 changes user role", %{user: user, organization: organization} do
membership = Organizations.get_membership!(organization.id, user.id)
test "update_member_role/3 changes user role", %{organization: organization} do
new_user = user_fixture()
{:ok, membership} =
Organizations.create_membership(%{
organization_id: organization.id,
user_id: new_user.id,
role: :member
})
assert membership.role == :member
assert {:ok, updated} =
Organizations.update_member_role(organization.id, new_user.id, :admin)
assert {:ok, updated} = Organizations.update_membership(membership, %{role: :admin})
assert updated.role == :admin
end
@ -407,8 +418,15 @@ defmodule Towerops.OrganizationsTest do
# Owner can delete organization
assert Organizations.can?(membership, :delete, :organization)
# Change to member role
{:ok, member_membership} = Organizations.update_membership(membership, %{role: :member})
# Create a separate member-level membership
member_user = user_fixture()
{:ok, member_membership} =
Organizations.create_membership(%{
organization_id: membership.organization_id,
user_id: member_user.id,
role: :member
})
# Member cannot delete organization
refute Organizations.can?(member_membership, :delete, :organization)

View file

@ -135,7 +135,11 @@ defmodule Towerops.RedisHealthCheckTest do
# Schedule recovery after a short delay — the first attempt will fail,
# then we restore the healthy state before the second attempt.
Task.async(fn ->
Process.sleep(5)
receive do
after
5 -> :ok
end
Fake.set_ping_response(Towerops.Redix, :ok)
end)

View file

@ -27,8 +27,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.fast_check(
client_opts,
fn ->
Process.sleep(30)
{:ok, "too slow"}
receive do
:never -> {:ok, "too slow"}
end
end,
timeout: 25
)
@ -131,8 +132,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.deferred_check(
client_opts,
fn ->
Process.sleep(30)
{:ok, "too slow"}
receive do
:never -> {:ok, "too slow"}
end
end,
timeout: 25,
name: "neighbors"
@ -177,8 +179,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
{:slow_check,
fn ->
Process.sleep(30)
{:ok, "slow"}
receive do
:never -> {:ok, "slow"}
end
end, [timeout: 25, default: []]}
]
@ -229,8 +232,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns false when check times out" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
Process.sleep(30)
{:ok, [1, 3, 6, 1, 4, 1, 9]}
receive do
:never -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
@ -252,9 +256,11 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns :slow for delayed responses" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
# Sleep just past the threshold to be classified as slow
Process.sleep(35)
{:ok, "Slow Device"}
# Delay just past the threshold to be classified as slow
receive do
after
35 -> {:ok, "Slow Device"}
end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]

View file

@ -88,8 +88,10 @@ defmodule Towerops.Snmp.PollerTest do
# Simulate a delayed response
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
Process.sleep(1)
{:ok, {:timeticks, 999}}
receive do
after
1 -> {:ok, {:timeticks, 999}}
end
end)
assert {:ok, response_time} = Poller.check_device(client_opts)

View file

@ -71,7 +71,6 @@ defmodule ToweropsWeb.MobileQRLiveTest do
assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token)
send(view.pid, :check_completion)
Process.sleep(50)
html = render(view)
# The template shows the QR page until @completed switches