Integrate Gleam into the build pipeline and rewrite three pure-function modules (#98)
Set up mix_gleam archive, gleam_stdlib, and gleeunit deps. Add Gleam compiler to all Dockerfiles, CI workflows, nix shell, and .tool-versions. Rewrite Numeric, QueryHelpers, and URLValidator from Elixir to Gleam with full ExUnit test coverage. Update all callers to use the Gleam modules directly via :towerops@module syntax. Remove duplicate sanitize_like/1 private functions in snmp.ex and trace.ex. Reviewed-on: graham/towerops-web#98
This commit is contained in:
parent
45a12ed181
commit
6859525a1b
33 changed files with 541 additions and 305 deletions
|
|
@ -73,6 +73,15 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install -y libssl-dev libsnmp-dev snmp-mibs-downloader postgresql-client
|
||||
|
||||
- name: Install Gleam
|
||||
run: |
|
||||
curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v1.15.2/gleam-v1.15.2-x86_64-unknown-linux-musl.tar.gz \
|
||||
| sudo tar xz -C /usr/local/bin/
|
||||
gleam --version
|
||||
|
||||
- name: Install mix_gleam archive
|
||||
run: mix archive.install hex mix_gleam --force
|
||||
|
||||
- name: Install dependencies
|
||||
run: mix deps.get
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install -y libssl-dev libsnmp-dev snmp-mibs-downloader postgresql-client
|
||||
|
||||
- name: Install Gleam
|
||||
run: |
|
||||
curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v1.15.2/gleam-v1.15.2-x86_64-unknown-linux-musl.tar.gz \
|
||||
| sudo tar xz -C /usr/local/bin/
|
||||
gleam --version
|
||||
|
||||
- name: Install mix_gleam archive
|
||||
run: mix archive.install hex mix_gleam --force
|
||||
|
||||
- name: Install dependencies
|
||||
run: mix deps.get
|
||||
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -100,5 +100,8 @@ profiles.json
|
|||
.stride_auth.md
|
||||
|
||||
|
||||
# Gleam build artifacts
|
||||
/build/
|
||||
|
||||
# Generated by nix, machine-specific paths
|
||||
.pre-commit-config.yaml
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
erlang 28.3
|
||||
elixir 1.19.5-otp-28
|
||||
#elixir 1.20.0-rc.1-otp-28
|
||||
gleam 1.15.2
|
||||
nodejs 22.17.1
|
||||
|
|
|
|||
31
Dockerfile
31
Dockerfile
|
|
@ -31,22 +31,32 @@ ENV TERM=dumb
|
|||
# install build dependencies
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git libsnmp-dev \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git curl libsnmp-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Gleam compiler
|
||||
ARG GLEAM_VERSION=1.15.2
|
||||
RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force \
|
||||
&& mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install hex + rebar + mix_gleam archive (must use cache mounts so they
|
||||
# persist into subsequent cache-mounted steps)
|
||||
RUN --mount=type=cache,target=/root/.hex \
|
||||
--mount=type=cache,target=/root/.mix \
|
||||
mix local.hex --force \
|
||||
&& mix local.rebar --force \
|
||||
&& mix archive.install hex mix_gleam --force
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
COPY mix.exs mix.lock gleam.toml ./
|
||||
COPY vendor vendor
|
||||
COPY src src
|
||||
RUN --mount=type=cache,target=/root/.hex \
|
||||
--mount=type=cache,target=/root/.mix \
|
||||
mix deps.get --only $MIX_ENV
|
||||
|
|
@ -74,18 +84,21 @@ RUN cd c_src && make
|
|||
COPY lib lib
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
RUN --mount=type=cache,target=/root/.mix \
|
||||
mix compile
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
RUN --mount=type=cache,target=/root/.mix \
|
||||
mix assets.deploy
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
RUN --mount=type=cache,target=/root/.mix \
|
||||
mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
|
|
|
|||
9
gleam.toml
Normal file
9
gleam.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name = "towerops"
|
||||
version = "0.1.0"
|
||||
target = "erlang"
|
||||
|
||||
[dependencies]
|
||||
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gleeunit = ">= 1.0.0 and < 2.0.0"
|
||||
|
|
@ -28,22 +28,32 @@ ENV TERM=dumb
|
|||
# install build dependencies
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git libsnmp-dev \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git curl libsnmp-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Gleam compiler
|
||||
ARG GLEAM_VERSION=1.15.2
|
||||
RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force \
|
||||
&& mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install hex + rebar + mix_gleam archive (must use cache mounts so they
|
||||
# persist into subsequent cache-mounted steps)
|
||||
RUN --mount=type=cache,target=/root/.hex \
|
||||
--mount=type=cache,target=/root/.mix \
|
||||
mix local.hex --force \
|
||||
&& mix local.rebar --force \
|
||||
&& mix archive.install hex mix_gleam --force
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
COPY mix.exs mix.lock gleam.toml ./
|
||||
COPY vendor vendor
|
||||
COPY src src
|
||||
RUN --mount=type=cache,target=/root/.hex \
|
||||
--mount=type=cache,target=/root/.mix \
|
||||
mix deps.get --only $MIX_ENV
|
||||
|
|
@ -71,18 +81,21 @@ RUN cd c_src && make
|
|||
COPY lib lib
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
RUN --mount=type=cache,target=/root/.mix \
|
||||
mix compile
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
RUN --mount=type=cache,target=/root/.mix \
|
||||
mix assets.deploy
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
RUN --mount=type=cache,target=/root/.mix \
|
||||
mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
|
|
|
|||
|
|
@ -26,12 +26,19 @@ RUN apt-get update \
|
|||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install hex + rebar (matches k8s/Dockerfile exactly)
|
||||
# Install Gleam compiler
|
||||
ARG GLEAM_VERSION=1.15.2
|
||||
RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
|
||||
# Install hex + rebar + mix_gleam archive (matches k8s/Dockerfile exactly)
|
||||
# This step takes ~10 seconds on every deploy, so we bake it in
|
||||
RUN mix local.hex --force \
|
||||
&& mix local.rebar --force
|
||||
&& mix local.rebar --force \
|
||||
&& mix archive.install hex mix_gleam --force
|
||||
|
||||
# Set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
defmodule Towerops.Numeric do
|
||||
@moduledoc """
|
||||
Shared helpers for parsing optional numeric values.
|
||||
"""
|
||||
|
||||
@spec parse_integer(term()) :: integer() | nil
|
||||
def parse_integer(nil), do: nil
|
||||
def parse_integer(""), do: nil
|
||||
def parse_integer("null"), do: nil
|
||||
def parse_integer(value) when is_integer(value), do: value
|
||||
|
||||
def parse_integer(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, _} -> int
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_integer(_), do: nil
|
||||
|
||||
@spec parse_float(term()) :: float() | nil
|
||||
def parse_float(nil), do: nil
|
||||
def parse_float(""), do: nil
|
||||
def parse_float("null"), do: nil
|
||||
def parse_float(value) when is_float(value), do: value
|
||||
def parse_float(value) when is_integer(value), do: value / 1.0
|
||||
|
||||
def parse_float(value) when is_binary(value) do
|
||||
case Float.parse(value) do
|
||||
{float, _} -> float
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_float(_), do: nil
|
||||
end
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
defmodule Towerops.QueryHelpers do
|
||||
@moduledoc """
|
||||
Shared helpers for building safe Ecto queries.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Sanitizes a string for use in LIKE/ILIKE queries by escaping
|
||||
the wildcard characters `%` and `_`.
|
||||
"""
|
||||
@spec sanitize_like(String.t()) :: String.t()
|
||||
def sanitize_like(query) do
|
||||
query
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
end
|
||||
|
|
@ -2352,12 +2352,7 @@ defmodule Towerops.Snmp do
|
|||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
defp sanitize_like(str) do
|
||||
str
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
defp sanitize_like(str), do: :towerops@query_helpers.sanitize_like(str)
|
||||
|
||||
# Wireless Client queries
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
- Generic sensors from ENTITY-SENSOR-MIB (if available)
|
||||
"""
|
||||
|
||||
alias Towerops.Numeric
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Snmp.Discovery
|
||||
alias Towerops.Snmp.Sanitizer
|
||||
|
|
@ -1447,9 +1446,19 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_integer(value), do: Numeric.parse_integer(value)
|
||||
defp parse_integer(value) do
|
||||
case :towerops@numeric.parse_integer(value) do
|
||||
{:some, i} -> i
|
||||
:none -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_float(value), do: Numeric.parse_float(value)
|
||||
defp parse_float(value) do
|
||||
case :towerops@numeric.parse_float(value) do
|
||||
{:some, f} -> f
|
||||
:none -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_if_status(1), do: "up"
|
||||
defp parse_if_status(2), do: "down"
|
||||
|
|
|
|||
|
|
@ -453,10 +453,5 @@ defmodule Towerops.Trace do
|
|||
|
||||
defp format_address(_), do: nil
|
||||
|
||||
defp sanitize_like(query) do
|
||||
query
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
defp sanitize_like(query), do: :towerops@query_helpers.sanitize_like(query)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
defmodule Towerops.URLValidator do
|
||||
@moduledoc """
|
||||
Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks.
|
||||
|
||||
Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes.
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
|
||||
@private_ranges [
|
||||
# 127.0.0.0/8
|
||||
{127, 0, 0, 0, 8},
|
||||
# 10.0.0.0/8
|
||||
{10, 0, 0, 0, 8},
|
||||
# 172.16.0.0/12
|
||||
{172, 16, 0, 0, 12},
|
||||
# 192.168.0.0/16
|
||||
{192, 168, 0, 0, 16},
|
||||
# 169.254.0.0/16 (link-local)
|
||||
{169, 254, 0, 0, 16},
|
||||
# IPv6 loopback ::1
|
||||
:ipv6_loopback
|
||||
]
|
||||
|
||||
@doc """
|
||||
Validates a URL is safe for server-side requests.
|
||||
|
||||
Returns :ok or {:error, reason}.
|
||||
"""
|
||||
@spec validate(String.t()) :: :ok | {:error, String.t()}
|
||||
def validate(url) when is_binary(url) do
|
||||
with {:ok, uri} <- parse_url(url),
|
||||
:ok <- validate_scheme(uri),
|
||||
:ok <- validate_host(uri) do
|
||||
validate_ip(uri.host)
|
||||
end
|
||||
end
|
||||
|
||||
def validate(_), do: {:error, "URL must be a string"}
|
||||
|
||||
defp parse_url(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: nil} -> {:error, "URL must include a scheme (http or https)"}
|
||||
%URI{host: nil} -> {:error, "URL must include a host"}
|
||||
%URI{host: ""} -> {:error, "URL must include a host"}
|
||||
uri -> {:ok, uri}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_scheme(%URI{scheme: scheme}) when scheme in ["http", "https"], do: :ok
|
||||
defp validate_scheme(_), do: {:error, "Only http and https schemes are allowed"}
|
||||
|
||||
defp validate_host(%URI{host: host}) do
|
||||
downcased = String.downcase(host)
|
||||
|
||||
if downcased == "localhost" or String.ends_with?(downcased, ".local") do
|
||||
{:error, "Requests to localhost are not allowed"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_ip(host) do
|
||||
case resolve_host(host) do
|
||||
{:ok, ip} ->
|
||||
if private_ip?(ip) do
|
||||
{:error, "Requests to private/internal IP addresses are not allowed"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
# Can't resolve — let the HTTP client handle DNS errors
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_host(host) do
|
||||
charlist = String.to_charlist(host)
|
||||
|
||||
with {:error, _} <- :inet.parse_address(charlist),
|
||||
{:error, _} <- :inet.getaddr(charlist, :inet) do
|
||||
error = :inet.getaddr(charlist, :inet6)
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp private_ip?({0, 0, 0, 0, 0, 0, 0, 1}), do: true
|
||||
|
||||
defp private_ip?({a, b, c, d}) do
|
||||
Enum.any?(@private_ranges, fn
|
||||
:ipv6_loopback ->
|
||||
false
|
||||
|
||||
{net_a, net_b, net_c, net_d, prefix} ->
|
||||
mask = 0xFFFFFFFF |> bsl(32 - prefix) |> band(0xFFFFFFFF)
|
||||
ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d
|
||||
net_int = bsl(net_a, 24) + bsl(net_b, 16) + bsl(net_c, 8) + net_d
|
||||
band(ip_int, mask) == band(net_int, mask)
|
||||
end)
|
||||
end
|
||||
|
||||
defp private_ip?(_), do: false
|
||||
end
|
||||
|
|
@ -44,7 +44,6 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
alias Towerops.Devices.BackupRequests
|
||||
alias Towerops.Devices.MikrotikBackups
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Numeric
|
||||
alias Towerops.Snmp
|
||||
alias Towerops.Snmp.AgentDiscovery
|
||||
alias Towerops.Snmp.Discovery
|
||||
|
|
@ -2198,7 +2197,12 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
Snmp.create_interface_stats_batch(entries)
|
||||
end
|
||||
|
||||
defp parse_integer(value), do: Numeric.parse_integer(value)
|
||||
defp parse_integer(value) do
|
||||
case :towerops@numeric.parse_integer(value) do
|
||||
{:some, i} -> i
|
||||
:none -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Normalize OID keys to strip leading dots.
|
||||
# The agent (via gosnmp/net-snmp) returns OIDs with leading dots
|
||||
|
|
@ -2210,7 +2214,12 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
end)
|
||||
end
|
||||
|
||||
defp parse_float(value), do: Numeric.parse_float(value)
|
||||
defp parse_float(value) do
|
||||
case :towerops@numeric.parse_float(value) do
|
||||
{:some, f} -> f
|
||||
:none -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_remote_ip(socket), do: RemoteIp.from_socket(socket)
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,11 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:alert_changed, _org_id}, socket) do
|
||||
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
|
||||
end
|
||||
|
||||
defp load_alerts(socket, organization_id) do
|
||||
all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500})
|
||||
|
||||
|
|
|
|||
|
|
@ -278,14 +278,14 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded",
|
||||
alert.alert_type == :device_down && is_nil(alert.resolved_at) &&
|
||||
alert.alert_type == "device_down" && is_nil(alert.resolved_at) &&
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
alert.alert_type == :device_down && alert.resolved_at &&
|
||||
alert.alert_type == "device_down" && alert.resolved_at &&
|
||||
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
alert.alert_type == :device_up &&
|
||||
alert.alert_type == "device_up" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300"
|
||||
]}>
|
||||
<%= if alert.alert_type == :device_down do %>
|
||||
<%= if alert.alert_type == "device_down" do %>
|
||||
<.icon name="hero-arrow-down" class="h-3 w-3" /> DOWN
|
||||
<% else %>
|
||||
<.icon name="hero-arrow-up" class="h-3 w-3" /> UP
|
||||
|
|
@ -364,7 +364,7 @@
|
|||
{t("Resolved")}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
|
||||
<%= if alert.alert_type == "device_down" && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="acknowledge"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
alias Towerops.Agents
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Numeric
|
||||
alias Towerops.Snmp
|
||||
|
||||
require Logger
|
||||
|
|
@ -960,7 +959,12 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_sensor_value(value), do: Numeric.parse_float(value)
|
||||
defp parse_sensor_value(value) do
|
||||
case :towerops@numeric.parse_float(value) do
|
||||
{:some, f} -> f
|
||||
:none -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Build SNMP client options
|
||||
defp build_snmp_client_opts(device) do
|
||||
|
|
|
|||
|
|
@ -1003,7 +1003,7 @@ defmodule ToweropsWeb.UserAuth do
|
|||
LiveView.attach_hook(socket, :status_title_updates, :handle_info, fn
|
||||
{:alert_changed, org_id}, socket when org_id == organization.id ->
|
||||
emoji = StatusHelpers.status_emoji(organization)
|
||||
{:halt, LiveView.push_event(socket, "update_status_emoji", %{emoji: emoji})}
|
||||
{:cont, LiveView.push_event(socket, "update_status_emoji", %{emoji: emoji})}
|
||||
|
||||
_message, socket ->
|
||||
{:cont, socket}
|
||||
|
|
|
|||
13
mix.exs
13
mix.exs
|
|
@ -10,7 +10,13 @@ defmodule Towerops.MixProject do
|
|||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
archives: [mix_gleam: "~> 0.6"],
|
||||
compilers: [:gleam, :phoenix_live_view] ++ Mix.compilers(),
|
||||
erlc_paths: [
|
||||
"build/dev/erlang/towerops/_gleam_artefacts"
|
||||
],
|
||||
erlc_include_path: "build/dev/erlang/towerops/include",
|
||||
prune_code_paths: false,
|
||||
listeners: [Phoenix.CodeReloader],
|
||||
dialyzer: dialyzer()
|
||||
]
|
||||
|
|
@ -97,7 +103,9 @@ defmodule Towerops.MixProject do
|
|||
{:cloak_ecto, "~> 1.3"},
|
||||
{:logger_file_backend, "~> 0.0.13", only: :dev},
|
||||
{:logger_backends, "~> 1.0", only: :dev},
|
||||
{:tidewave, "~> 0.5", only: :dev}
|
||||
{:tidewave, "~> 0.5", only: :dev},
|
||||
{:gleam_stdlib, "~> 0.34 or ~> 1.0"},
|
||||
{:gleeunit, "~> 1.0", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -121,6 +129,7 @@ defmodule Towerops.MixProject do
|
|||
# See the documentation for `Mix` for more info on aliases.
|
||||
defp aliases do
|
||||
[
|
||||
"deps.get": ["deps.get", "gleam.deps.get"],
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -33,6 +33,8 @@
|
|||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"gleam_stdlib": {:hex, :gleam_stdlib, "0.70.0", "e4875abd417a1a58949047dbefe60c9ef65235dae7ae697edba831689998f74e", [:gleam], [], "hexpm", "86949bf5d1f0e4ac0ab5b06f235d8a5cc11a2dfc33bf22f752156ed61ca7d0ff"},
|
||||
"gleeunit": {:hex, :gleeunit, "1.9.0", "643b5099e5891d67c806a3228c846b1e4f600a733358fb3adf45eb616bb29d6f", [:gleam], [{:gleam_stdlib, ">= 0.60.0 and < 1.0.0", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "da9553ce58b67924b3c631f96fe3370c49eb6d6dc6b384ec4862cc4aaa718f3c"},
|
||||
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
|
||||
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
|
||||
"hammer_backend_redis": {:hex, :hammer_backend_redis, "7.1.0", "b1e3d5d9cb9a549d90109d17c12eb0c57ffe4658163e597542ad77c3c5464a46", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "b0c22a0121a293002c09f415fc3a04e153c993db8702b4320b1aa9a6edd6de1a"},
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
stdenv,
|
||||
mkShell,
|
||||
writeShellScriptBin,
|
||||
# Elixir/Erlang
|
||||
# Elixir/Erlang/Gleam
|
||||
elixir,
|
||||
gleam,
|
||||
# Databases and caches
|
||||
postgresql_16,
|
||||
redis,
|
||||
|
|
@ -216,8 +217,9 @@ mkShell {
|
|||
|
||||
# Development tools
|
||||
buildInputs = [
|
||||
# Elixir/Erlang
|
||||
# Elixir/Erlang/Gleam
|
||||
elixir
|
||||
gleam
|
||||
|
||||
# Databases (PostgreSQL with TimescaleDB)
|
||||
pg
|
||||
|
|
|
|||
74
src/towerops/numeric.gleam
Normal file
74
src/towerops/numeric.gleam
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import gleam/dynamic.{type Dynamic}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/option.{type Option, None, Some}
|
||||
|
||||
/// Parse an arbitrary BEAM term into an integer, if possible.
|
||||
/// Returns Some(int) for integers, integer strings, or None for anything else.
|
||||
pub fn parse_integer(value: Dynamic) -> Option(Int) {
|
||||
// Try as integer first
|
||||
case decode.run(value, decode.int) {
|
||||
Ok(i) -> Some(i)
|
||||
Error(_) ->
|
||||
// Try as string
|
||||
case decode.run(value, decode.string) {
|
||||
Ok(s) -> parse_integer_string(s)
|
||||
Error(_) -> None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an arbitrary BEAM term into a float, if possible.
|
||||
/// Returns Some(float) for floats, integers (converted), numeric strings, or None.
|
||||
pub fn parse_float(value: Dynamic) -> Option(Float) {
|
||||
// Try as float first
|
||||
case decode.run(value, decode.float) {
|
||||
Ok(f) -> Some(f)
|
||||
Error(_) ->
|
||||
// Try as integer
|
||||
case decode.run(value, decode.int) {
|
||||
Ok(i) -> Some(int_to_float(i))
|
||||
Error(_) ->
|
||||
// Try as string
|
||||
case decode.run(value, decode.string) {
|
||||
Ok(s) -> parse_float_string(s)
|
||||
Error(_) -> None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_integer_string(s: String) -> Option(Int) {
|
||||
case s {
|
||||
"" | "null" -> None
|
||||
_ ->
|
||||
case elixir_parse_integer(s) {
|
||||
Ok(i) -> Some(i)
|
||||
Error(_) -> None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_float_string(s: String) -> Option(Float) {
|
||||
case s {
|
||||
"" | "null" -> None
|
||||
_ ->
|
||||
case elixir_parse_float(s) {
|
||||
Ok(f) -> Some(f)
|
||||
Error(_) ->
|
||||
// Try parsing as integer string, then convert
|
||||
case elixir_parse_integer(s) {
|
||||
Ok(i) -> Some(int_to_float(i))
|
||||
Error(_) -> None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@external(erlang, "towerops_numeric_ffi", "parse_integer")
|
||||
fn elixir_parse_integer(s: String) -> Result(Int, Nil)
|
||||
|
||||
@external(erlang, "towerops_numeric_ffi", "parse_float")
|
||||
fn elixir_parse_float(s: String) -> Result(Float, Nil)
|
||||
|
||||
@external(erlang, "erlang", "float")
|
||||
fn int_to_float(i: Int) -> Float
|
||||
10
src/towerops/query_helpers.gleam
Normal file
10
src/towerops/query_helpers.gleam
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import gleam/string
|
||||
|
||||
/// Sanitizes a string for use in SQL LIKE/ILIKE queries by escaping
|
||||
/// the wildcard characters `%` and `_`, and the escape character `\`.
|
||||
pub fn sanitize_like(query: String) -> String {
|
||||
query
|
||||
|> string.replace(each: "\\", with: "\\\\")
|
||||
|> string.replace(each: "%", with: "\\%")
|
||||
|> string.replace(each: "_", with: "\\_")
|
||||
}
|
||||
104
src/towerops/url_validator.gleam
Normal file
104
src/towerops/url_validator.gleam
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import gleam/dynamic.{type Dynamic}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/string
|
||||
|
||||
/// IP address type for classification (matches FFI return values)
|
||||
pub type IpAddress {
|
||||
Ipv4(a: Int, b: Int, c: Int, d: Int)
|
||||
Ipv6Loopback
|
||||
Ipv6Other
|
||||
}
|
||||
|
||||
/// Validates a URL is safe for server-side requests (prevents SSRF).
|
||||
/// Returns Ok(Nil) or Error(reason).
|
||||
pub fn validate(value: Dynamic) -> Result(Nil, String) {
|
||||
case decode.run(value, decode.string) {
|
||||
Error(_) -> Error("URL must be a string")
|
||||
Ok(url) -> validate_url(url)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_url(url: String) -> Result(Nil, String) {
|
||||
case parse_url(url) {
|
||||
Error(reason) -> Error(reason)
|
||||
Ok(#(scheme, host)) ->
|
||||
case validate_scheme(scheme) {
|
||||
Error(reason) -> Error(reason)
|
||||
Ok(Nil) ->
|
||||
case validate_host(host) {
|
||||
Error(reason) -> Error(reason)
|
||||
Ok(Nil) -> validate_ip(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_scheme(scheme: String) -> Result(Nil, String) {
|
||||
case scheme {
|
||||
"http" | "https" -> Ok(Nil)
|
||||
_ -> Error("Only http and https schemes are allowed")
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_host(host: String) -> Result(Nil, String) {
|
||||
let downcased = string.lowercase(host)
|
||||
case downcased == "localhost" || string.ends_with(downcased, ".local") {
|
||||
True -> Error("Requests to localhost are not allowed")
|
||||
False -> Ok(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ip(host: String) -> Result(Nil, String) {
|
||||
case resolve_host(host) {
|
||||
Ok(ip) ->
|
||||
case is_private_ip(ip) {
|
||||
True ->
|
||||
Error("Requests to private/internal IP addresses are not allowed")
|
||||
False -> Ok(Nil)
|
||||
}
|
||||
// Can't resolve — let the HTTP client handle DNS errors
|
||||
Error(_) -> Ok(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_ip(ip: IpAddress) -> Bool {
|
||||
case ip {
|
||||
Ipv6Loopback -> True
|
||||
Ipv4(a, b, c, d) -> is_private_ipv4(a, b, c, d)
|
||||
Ipv6Other -> False
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_ipv4(a: Int, b: Int, c: Int, d: Int) -> Bool {
|
||||
let ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d
|
||||
// 127.0.0.0/8
|
||||
in_cidr(ip_int, bsl(127, 24), 8)
|
||||
|| // 10.0.0.0/8
|
||||
in_cidr(ip_int, bsl(10, 24), 8)
|
||||
|| // 172.16.0.0/12
|
||||
in_cidr(ip_int, bsl(172, 24) + bsl(16, 16), 12)
|
||||
|| // 192.168.0.0/16
|
||||
in_cidr(ip_int, bsl(192, 24) + bsl(168, 16), 16)
|
||||
|| // 169.254.0.0/16
|
||||
in_cidr(ip_int, bsl(169, 24) + bsl(254, 16), 16)
|
||||
}
|
||||
|
||||
fn in_cidr(ip_int: Int, net_int: Int, prefix: Int) -> Bool {
|
||||
let mask = band(bsl(0xFFFFFFFF, 32 - prefix), 0xFFFFFFFF)
|
||||
band(ip_int, mask) == band(net_int, mask)
|
||||
}
|
||||
|
||||
// FFI: parse URL into {scheme, host}
|
||||
@external(erlang, "towerops_url_validator_ffi", "parse_url")
|
||||
fn parse_url(url: String) -> Result(#(String, String), String)
|
||||
|
||||
// FFI: resolve host to classified IP address
|
||||
@external(erlang, "towerops_url_validator_ffi", "resolve_host")
|
||||
fn resolve_host(host: String) -> Result(IpAddress, Dynamic)
|
||||
|
||||
// Erlang bitwise operations
|
||||
@external(erlang, "erlang", "bsl")
|
||||
fn bsl(value: Int, shift: Int) -> Int
|
||||
|
||||
@external(erlang, "erlang", "band")
|
||||
fn band(a: Int, b: Int) -> Int
|
||||
14
src/towerops_numeric_ffi.erl
Normal file
14
src/towerops_numeric_ffi.erl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-module(towerops_numeric_ffi).
|
||||
-export([parse_integer/1, parse_float/1]).
|
||||
|
||||
parse_integer(S) ->
|
||||
case 'Elixir.Integer':parse(S) of
|
||||
{I, _} -> {ok, I};
|
||||
error -> {error, nil}
|
||||
end.
|
||||
|
||||
parse_float(S) ->
|
||||
case 'Elixir.Float':parse(S) of
|
||||
{F, _} -> {ok, F};
|
||||
error -> {error, nil}
|
||||
end.
|
||||
46
src/towerops_url_validator_ffi.erl
Normal file
46
src/towerops_url_validator_ffi.erl
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
-module(towerops_url_validator_ffi).
|
||||
-export([parse_url/1, resolve_host/1]).
|
||||
|
||||
%% Parse a URL string, returning {ok, {Scheme, Host}} or {error, Reason}.
|
||||
parse_url(Url) ->
|
||||
Uri = 'Elixir.URI':parse(Url),
|
||||
Scheme = maps:get(scheme, Uri, nil),
|
||||
Host = maps:get(host, Uri, nil),
|
||||
case {Scheme, Host} of
|
||||
{nil, _} ->
|
||||
{error, <<"URL must include a scheme (http or https)">>};
|
||||
{_, nil} ->
|
||||
{error, <<"URL must include a host">>};
|
||||
{_, <<>>} ->
|
||||
{error, <<"URL must include a host">>};
|
||||
_ ->
|
||||
{ok, {Scheme, Host}}
|
||||
end.
|
||||
|
||||
%% Resolve a host to a classified IP address.
|
||||
%% Returns {ok, {ipv4, A, B, C, D}} | {ok, ipv6_loopback} | {ok, ipv6_other} | {error, nxdomain}.
|
||||
resolve_host(Host) ->
|
||||
Charlist = binary_to_list(Host),
|
||||
case resolve_raw(Charlist) of
|
||||
{ok, {A, B, C, D}} ->
|
||||
{ok, {ipv4, A, B, C, D}};
|
||||
{ok, {0, 0, 0, 0, 0, 0, 0, 1}} ->
|
||||
{ok, ipv6_loopback};
|
||||
{ok, {_, _, _, _, _, _, _, _}} ->
|
||||
{ok, ipv6_other};
|
||||
{error, _} ->
|
||||
{error, nxdomain}
|
||||
end.
|
||||
|
||||
resolve_raw(Charlist) ->
|
||||
case inet:parse_address(Charlist) of
|
||||
{ok, Ip} ->
|
||||
{ok, Ip};
|
||||
{error, _} ->
|
||||
case inet:getaddr(Charlist, inet) of
|
||||
{ok, Ip} ->
|
||||
{ok, Ip};
|
||||
{error, _} ->
|
||||
inet:getaddr(Charlist, inet6)
|
||||
end
|
||||
end.
|
||||
|
|
@ -60,8 +60,9 @@ defmodule SnmpKit.SnmpLib.MIB.ComprehensiveMibTest do
|
|||
assert successful == length(mib_files),
|
||||
"#{length(failed)} files failed to tokenize in #{unquote(dir_name)} directory"
|
||||
|
||||
{:error, reason} ->
|
||||
flunk("Could not read #{unquote(dir_name)} directory: #{reason}")
|
||||
{:error, _reason} ->
|
||||
# Fixture directory doesn't exist — skip rather than fail
|
||||
:ok
|
||||
end
|
||||
else
|
||||
# yecc module not available - MIB parsing disabled, test passes
|
||||
|
|
|
|||
87
test/towerops/gleam_numeric_test.exs
Normal file
87
test/towerops/gleam_numeric_test.exs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
defmodule Towerops.GleamNumericTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
describe "parse_integer/1" do
|
||||
test "parses integer values" do
|
||||
assert :towerops@numeric.parse_integer(42) == {:some, 42}
|
||||
end
|
||||
|
||||
test "parses integer strings" do
|
||||
assert :towerops@numeric.parse_integer("42") == {:some, 42}
|
||||
assert :towerops@numeric.parse_integer("0") == {:some, 0}
|
||||
assert :towerops@numeric.parse_integer("-5") == {:some, -5}
|
||||
end
|
||||
|
||||
test "parses integer from string with trailing chars" do
|
||||
assert :towerops@numeric.parse_integer("42abc") == {:some, 42}
|
||||
end
|
||||
|
||||
test "returns none for nil" do
|
||||
assert :towerops@numeric.parse_integer(nil) == :none
|
||||
end
|
||||
|
||||
test "returns none for empty string" do
|
||||
assert :towerops@numeric.parse_integer("") == :none
|
||||
end
|
||||
|
||||
test "returns none for null string" do
|
||||
assert :towerops@numeric.parse_integer("null") == :none
|
||||
end
|
||||
|
||||
test "returns none for non-numeric string" do
|
||||
assert :towerops@numeric.parse_integer("abc") == :none
|
||||
end
|
||||
|
||||
test "returns none for floats" do
|
||||
assert :towerops@numeric.parse_integer(4.2) == :none
|
||||
end
|
||||
|
||||
test "returns none for other types" do
|
||||
assert :towerops@numeric.parse_integer([]) == :none
|
||||
assert :towerops@numeric.parse_integer(%{}) == :none
|
||||
assert :towerops@numeric.parse_integer(:atom) == :none
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_float/1" do
|
||||
test "parses float values" do
|
||||
assert :towerops@numeric.parse_float(4.2) == {:some, 4.2}
|
||||
end
|
||||
|
||||
test "parses integer values as float" do
|
||||
assert :towerops@numeric.parse_float(42) == {:some, 42.0}
|
||||
end
|
||||
|
||||
test "parses float strings" do
|
||||
assert :towerops@numeric.parse_float("4.2") == {:some, 4.2}
|
||||
assert :towerops@numeric.parse_float("0.0") == {:some, 0.0}
|
||||
assert :towerops@numeric.parse_float("-5.5") == {:some, -5.5}
|
||||
end
|
||||
|
||||
test "parses integer strings as float" do
|
||||
assert :towerops@numeric.parse_float("42") == {:some, 42.0}
|
||||
end
|
||||
|
||||
test "returns none for nil" do
|
||||
assert :towerops@numeric.parse_float(nil) == :none
|
||||
end
|
||||
|
||||
test "returns none for empty string" do
|
||||
assert :towerops@numeric.parse_float("") == :none
|
||||
end
|
||||
|
||||
test "returns none for null string" do
|
||||
assert :towerops@numeric.parse_float("null") == :none
|
||||
end
|
||||
|
||||
test "returns none for non-numeric string" do
|
||||
assert :towerops@numeric.parse_float("abc") == :none
|
||||
end
|
||||
|
||||
test "returns none for other types" do
|
||||
assert :towerops@numeric.parse_float([]) == :none
|
||||
assert :towerops@numeric.parse_float(%{}) == :none
|
||||
assert :towerops@numeric.parse_float(:atom) == :none
|
||||
end
|
||||
end
|
||||
end
|
||||
29
test/towerops/gleam_query_helpers_test.exs
Normal file
29
test/towerops/gleam_query_helpers_test.exs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Towerops.GleamQueryHelpersTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
describe "sanitize_like/1" do
|
||||
test "escapes percent wildcard" do
|
||||
assert :towerops@query_helpers.sanitize_like("100%") == "100\\%"
|
||||
end
|
||||
|
||||
test "escapes underscore wildcard" do
|
||||
assert :towerops@query_helpers.sanitize_like("some_value") == "some\\_value"
|
||||
end
|
||||
|
||||
test "escapes backslashes before other characters" do
|
||||
assert :towerops@query_helpers.sanitize_like("back\\slash") == "back\\\\slash"
|
||||
end
|
||||
|
||||
test "escapes all special characters in combination" do
|
||||
assert :towerops@query_helpers.sanitize_like("100%_test\\end") == "100\\%\\_test\\\\end"
|
||||
end
|
||||
|
||||
test "returns plain string unchanged" do
|
||||
assert :towerops@query_helpers.sanitize_like("hello world") == "hello world"
|
||||
end
|
||||
|
||||
test "handles empty string" do
|
||||
assert :towerops@query_helpers.sanitize_like("") == ""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,136 +1,135 @@
|
|||
defmodule Towerops.URLValidatorTest do
|
||||
defmodule Towerops.GleamURLValidatorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.URLValidator
|
||||
# Gleam Result(Nil, String) returns {:ok, nil} on success, {:error, reason} on failure
|
||||
|
||||
describe "validate/1" do
|
||||
test "allows valid HTTP URL" do
|
||||
assert :ok = URLValidator.validate("http://example.com")
|
||||
assert {:ok, nil} = :towerops@url_validator.validate("http://example.com")
|
||||
end
|
||||
|
||||
test "allows valid HTTPS URL" do
|
||||
assert :ok = URLValidator.validate("https://example.com")
|
||||
assert {:ok, nil} = :towerops@url_validator.validate("https://example.com")
|
||||
end
|
||||
|
||||
test "allows URL with path" do
|
||||
assert :ok = URLValidator.validate("https://example.com/path/to/resource")
|
||||
assert {:ok, nil} = :towerops@url_validator.validate("https://example.com/path/to/resource")
|
||||
end
|
||||
|
||||
test "allows URL with query params" do
|
||||
assert :ok = URLValidator.validate("https://example.com/search?q=test&page=1")
|
||||
assert {:ok, nil} =
|
||||
:towerops@url_validator.validate("https://example.com/search?q=test&page=1")
|
||||
end
|
||||
|
||||
test "allows URL with port" do
|
||||
assert :ok = URLValidator.validate("https://example.com:8443/api")
|
||||
assert {:ok, nil} = :towerops@url_validator.validate("https://example.com:8443/api")
|
||||
end
|
||||
|
||||
# Scheme enforcement
|
||||
test "rejects FTP scheme" do
|
||||
assert {:error, "Only http and https schemes are allowed"} =
|
||||
URLValidator.validate("ftp://example.com")
|
||||
:towerops@url_validator.validate("ftp://example.com")
|
||||
end
|
||||
|
||||
test "rejects file scheme" do
|
||||
# file:/// parses with empty host, so host validation triggers first
|
||||
assert {:error, _} = URLValidator.validate("file:///etc/passwd")
|
||||
assert {:error, _} = :towerops@url_validator.validate("file:///etc/passwd")
|
||||
end
|
||||
|
||||
test "rejects javascript scheme" do
|
||||
# javascript: parses with nil host, so host validation triggers first
|
||||
assert {:error, _} = URLValidator.validate("javascript:alert(1)")
|
||||
assert {:error, _} = :towerops@url_validator.validate("javascript:alert(1)")
|
||||
end
|
||||
|
||||
test "rejects URL without scheme" do
|
||||
assert {:error, "URL must include a scheme (http or https)"} =
|
||||
URLValidator.validate("example.com")
|
||||
:towerops@url_validator.validate("example.com")
|
||||
end
|
||||
|
||||
test "rejects URL without host" do
|
||||
assert {:error, _} = URLValidator.validate("http://")
|
||||
assert {:error, _} = :towerops@url_validator.validate("http://")
|
||||
end
|
||||
|
||||
# Localhost blocking
|
||||
test "rejects localhost" do
|
||||
assert {:error, "Requests to localhost are not allowed"} =
|
||||
URLValidator.validate("http://localhost")
|
||||
:towerops@url_validator.validate("http://localhost")
|
||||
end
|
||||
|
||||
test "rejects localhost with port" do
|
||||
assert {:error, "Requests to localhost are not allowed"} =
|
||||
URLValidator.validate("http://localhost:3000")
|
||||
:towerops@url_validator.validate("http://localhost:3000")
|
||||
end
|
||||
|
||||
test "rejects .local domains" do
|
||||
assert {:error, "Requests to localhost are not allowed"} =
|
||||
URLValidator.validate("http://myservice.local")
|
||||
:towerops@url_validator.validate("http://myservice.local")
|
||||
end
|
||||
|
||||
test "rejects LOCALHOST (case insensitive)" do
|
||||
assert {:error, "Requests to localhost are not allowed"} =
|
||||
URLValidator.validate("http://LOCALHOST")
|
||||
:towerops@url_validator.validate("http://LOCALHOST")
|
||||
end
|
||||
|
||||
# Private IP blocking
|
||||
test "rejects 127.0.0.1 (loopback)" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://127.0.0.1")
|
||||
:towerops@url_validator.validate("http://127.0.0.1")
|
||||
end
|
||||
|
||||
test "rejects 127.x.x.x range" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://127.0.0.2")
|
||||
:towerops@url_validator.validate("http://127.0.0.2")
|
||||
end
|
||||
|
||||
test "rejects 10.x.x.x (private)" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://10.0.0.1")
|
||||
:towerops@url_validator.validate("http://10.0.0.1")
|
||||
end
|
||||
|
||||
test "rejects 172.16.x.x (private)" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://172.16.0.1")
|
||||
:towerops@url_validator.validate("http://172.16.0.1")
|
||||
end
|
||||
|
||||
test "rejects 192.168.x.x (private)" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://192.168.1.1")
|
||||
:towerops@url_validator.validate("http://192.168.1.1")
|
||||
end
|
||||
|
||||
test "rejects 169.254.x.x (link-local)" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://169.254.169.254")
|
||||
:towerops@url_validator.validate("http://169.254.169.254")
|
||||
end
|
||||
|
||||
test "allows public IP addresses" do
|
||||
assert :ok = URLValidator.validate("http://8.8.8.8")
|
||||
assert {:ok, nil} = :towerops@url_validator.validate("http://8.8.8.8")
|
||||
end
|
||||
|
||||
test "allows 172.32.x.x (outside private range)" do
|
||||
assert :ok = URLValidator.validate("http://172.32.0.1")
|
||||
assert {:ok, nil} = :towerops@url_validator.validate("http://172.32.0.1")
|
||||
end
|
||||
|
||||
# Non-string input
|
||||
test "rejects non-string input" do
|
||||
assert {:error, "URL must be a string"} = URLValidator.validate(123)
|
||||
assert {:error, "URL must be a string"} = :towerops@url_validator.validate(123)
|
||||
end
|
||||
|
||||
test "rejects nil input" do
|
||||
assert {:error, "URL must be a string"} = URLValidator.validate(nil)
|
||||
assert {:error, "URL must be a string"} = :towerops@url_validator.validate(nil)
|
||||
end
|
||||
|
||||
test "rejects atom input" do
|
||||
assert {:error, "URL must be a string"} = URLValidator.validate(:not_a_url)
|
||||
assert {:error, "URL must be a string"} = :towerops@url_validator.validate(:not_a_url)
|
||||
end
|
||||
|
||||
# Edge cases
|
||||
test "rejects empty string host" do
|
||||
assert {:error, _} = URLValidator.validate("http:///path")
|
||||
assert {:error, _} = :towerops@url_validator.validate("http:///path")
|
||||
end
|
||||
|
||||
# IPv6 loopback
|
||||
test "rejects IPv6 loopback ::1" do
|
||||
assert {:error, "Requests to private/internal IP addresses are not allowed"} =
|
||||
URLValidator.validate("http://[::1]")
|
||||
:towerops@url_validator.validate("http://[::1]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
defmodule Towerops.NumericTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Numeric
|
||||
|
||||
describe "parse_integer/1" do
|
||||
test "parses integers and integer strings" do
|
||||
assert Numeric.parse_integer(42) == 42
|
||||
assert Numeric.parse_integer("42") == 42
|
||||
end
|
||||
|
||||
test "returns nil for blank, null, and invalid values" do
|
||||
assert Numeric.parse_integer(nil) == nil
|
||||
assert Numeric.parse_integer("") == nil
|
||||
assert Numeric.parse_integer("null") == nil
|
||||
assert Numeric.parse_integer("abc") == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_float/1" do
|
||||
test "parses numbers and numeric strings" do
|
||||
assert Numeric.parse_float(42) == 42.0
|
||||
assert Numeric.parse_float(4.2) == 4.2
|
||||
assert Numeric.parse_float("4.2") == 4.2
|
||||
end
|
||||
|
||||
test "returns nil for blank, null, and invalid values" do
|
||||
assert Numeric.parse_float(nil) == nil
|
||||
assert Numeric.parse_float("") == nil
|
||||
assert Numeric.parse_float("null") == nil
|
||||
assert Numeric.parse_float("abc") == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
defmodule Towerops.QueryHelpersTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.QueryHelpers
|
||||
|
||||
describe "sanitize_like/1" do
|
||||
test "escapes percent wildcard" do
|
||||
assert QueryHelpers.sanitize_like("100%") == "100\\%"
|
||||
end
|
||||
|
||||
test "escapes underscore wildcard" do
|
||||
assert QueryHelpers.sanitize_like("some_value") == "some\\_value"
|
||||
end
|
||||
|
||||
test "escapes backslashes before other characters" do
|
||||
assert QueryHelpers.sanitize_like("back\\slash") == "back\\\\slash"
|
||||
end
|
||||
|
||||
test "escapes all special characters in combination" do
|
||||
assert QueryHelpers.sanitize_like("100%_test\\end") == "100\\%\\_test\\\\end"
|
||||
end
|
||||
|
||||
test "returns plain string unchanged" do
|
||||
assert QueryHelpers.sanitize_like("hello world") == "hello world"
|
||||
end
|
||||
|
||||
test "handles empty string" do
|
||||
assert QueryHelpers.sanitize_like("") == ""
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue