test: in-process Redis fake instead of skipping Redis-backed tests

Replaces the previous :requires_redis skip with a real test double so
the suite stays self-contained — no external Redis service needed in
CI, but the call paths through RedisHealthCheck and FourOhFourTracker
still get exercised end-to-end.

- Towerops.Redix.Fake: GenServer that speaks PING / SADD / SCARD /
  SISMEMBER / SMEMBERS / EXPIRE / DEL / FLUSHDB. start_link refuses
  anything other than localhost:6379 so failure-path tests still fail
  the way they did against real Redix.
- Application supervisor starts the fake under name Towerops.Redix in
  test env (instead of the dummy Agent stub).
- RedisHealthCheck and FourOhFourTracker now route Redix calls through
  Application.get_env(:towerops, :redix_module, Redix). Test config
  points it at the fake.
- Drops the test_helper :requires_redis exclusion and the @moduletag
  on the two test files; FourOhFourTrackerTest setup just resets the
  fake between runs.
This commit is contained in:
Graham McIntire 2026-05-10 11:32:50 -05:00
parent 670516f641
commit 67d8be6b89
8 changed files with 169 additions and 85 deletions

View file

@ -127,5 +127,8 @@ config :towerops,
device_deletion_delay_ms: 10,
# Reduce DiscoveryWorker poll cadence (25ms vs 500ms in prod)
discovery_wait_interval_ms: 25,
# Use the in-process Redis fake so tests don't depend on a real
# Redis service (CI has none).
redix_module: Towerops.Redix.Fake,
# Stripe test configuration
stripe_product_id: "prod_test"

View file

@ -214,26 +214,33 @@ defmodule Towerops.Application do
defp redis_spec do
redis_config = Application.get_env(:towerops, :redis, [])
if Keyword.has_key?(redis_config, :host) do
opts = [
host: Keyword.get(redis_config, :host, "localhost"),
port: Keyword.get(redis_config, :port, 6379),
name: Towerops.Redix
]
cond do
# In test, run an in-process Redis fake so the suite stays
# self-contained — CI doesn't ship a Redis service.
Application.get_env(:towerops, :env) == :test ->
{Towerops.Redix.Fake, name: Towerops.Redix}
# Add password if configured
opts =
case Keyword.get(redis_config, :password) do
nil -> opts
"" -> opts
password -> Keyword.put(opts, :password, password)
end
Keyword.has_key?(redis_config, :host) ->
opts = [
host: Keyword.get(redis_config, :host, "localhost"),
port: Keyword.get(redis_config, :port, 6379),
name: Towerops.Redix
]
{Redix, opts}
else
# No Redis configured - start a stub process that will fail gracefully
# This allows the app to start in environments without Redis
Supervisor.child_spec({Agent, fn -> nil end}, id: Towerops.Redix)
# Add password if configured
opts =
case Keyword.get(redis_config, :password) do
nil -> opts
"" -> opts
password -> Keyword.put(opts, :password, password)
end
{Redix, opts}
true ->
# No Redis configured - start a stub process that will fail gracefully
# This allows the app to start in environments without Redis
Supervisor.child_spec({Agent, fn -> nil end}, id: Towerops.Redix)
end
end

View file

@ -90,16 +90,18 @@ defmodule Towerops.RedisHealthCheck do
conn_opts
end
redix = redix_module()
# Start a temporary connection for health check
case Redix.start_link(conn_opts) do
case redix.start_link(conn_opts) do
{:ok, conn} ->
result =
case Redix.command(conn, ["PING"], timeout: timeout_ms) do
case redix.command(conn, ["PING"], timeout: timeout_ms) do
{:ok, "PONG"} -> :ok
{:error, reason} -> {:error, reason}
end
Redix.stop(conn)
redix.stop(conn)
result
{:error, reason} ->
@ -115,4 +117,9 @@ defmodule Towerops.RedisHealthCheck do
Logger.debug("Redis health check exit: #{inspect(reason)}")
{:error, reason}
end
# Allows the test suite to swap in `Towerops.Redix.Fake` so health
# checks don't reach out to a real Redis. Defaults to `Redix` in
# every other environment.
defp redix_module, do: Application.get_env(:towerops, :redix_module, Redix)
end

116
lib/towerops/redix/fake.ex Normal file
View file

@ -0,0 +1,116 @@
defmodule Towerops.Redix.Fake do
@moduledoc """
In-process stand-in for Redix that speaks just enough of the Redis
command protocol for our test suite. CI doesn't run a Redis service,
and we don't want our unit tests to depend on one — anything in the
Towerops codebase that talks to Redis goes through a configurable
client module (`:towerops, :redix_module`), and tests point that at
this fake.
Supported commands: `PING`, `SADD`, `SCARD`, `SISMEMBER`, `SMEMBERS`,
`EXPIRE`, `DEL`, `FLUSHDB`. Anything else returns `{:error,
:unsupported_command}` extend as new commands appear in the codebase.
The signature mirrors `Redix.start_link/1`, `command/3`, and `stop/1`
so the modules using it stay agnostic of which client is wired up.
"""
use GenServer
@reachable_hosts ["localhost", "127.0.0.1", "::1"]
# ── Redix-compatible API ────────────────────────────────────────────
def start_link(opts \\ []) do
# Simulate connection refusal when the caller asks for anything that
# isn't standard localhost:6379. RedisHealthCheck failure-path tests
# dial unreachable hosts/ports specifically to drive error branches,
# and we'd silently return `{:ok, _}` if we accepted everything.
host = Keyword.get(opts, :host, "localhost")
port = Keyword.get(opts, :port, 6379)
if host in @reachable_hosts and port == 6379 do
{name, _opts} = Keyword.pop(opts, :name)
GenServer.start_link(__MODULE__, %{}, if(name, do: [name: name], else: []))
else
{:error, %{__struct__: Redix.ConnectionError, reason: :econnrefused}}
end
end
def command(conn, cmd, _opts \\ []) do
GenServer.call(conn, {:command, cmd})
end
def stop(conn) do
if Process.alive?(conn), do: GenServer.stop(conn, :normal), else: :ok
end
@doc """
Resets the fake Redis state. Use in test setup blocks to keep tests
isolated.
"""
def reset(conn \\ __MODULE__) do
GenServer.call(conn, :reset)
end
# ── GenServer ────────────────────────────────────────────────────────
@impl true
def init(_), do: {:ok, %{sets: %{}}}
@impl true
def handle_call({:command, cmd}, _from, state) do
{reply, state} = run(cmd, state)
{:reply, reply, state}
end
def handle_call(:reset, _from, _state), do: {:reply, :ok, %{sets: %{}}}
# ── Command dispatch ────────────────────────────────────────────────
defp run(["PING"], state), do: {{:ok, "PONG"}, state}
defp run(["PING" | _], state), do: {{:ok, "PONG"}, state}
defp run(["SADD", key, value | rest], state) do
set = Map.get(state.sets, key, MapSet.new())
members = [value | rest]
{new_set, added} =
Enum.reduce(members, {set, 0}, fn m, {acc, count} ->
if MapSet.member?(acc, m), do: {acc, count}, else: {MapSet.put(acc, m), count + 1}
end)
{{:ok, added}, %{state | sets: Map.put(state.sets, key, new_set)}}
end
defp run(["SCARD", key], state) do
size = state.sets |> Map.get(key, MapSet.new()) |> MapSet.size()
{{:ok, size}, state}
end
defp run(["SISMEMBER", key, value], state) do
in? = state.sets |> Map.get(key, MapSet.new()) |> MapSet.member?(value)
{{:ok, if(in?, do: 1, else: 0)}, state}
end
defp run(["SMEMBERS", key], state) do
members = state.sets |> Map.get(key, MapSet.new()) |> MapSet.to_list()
{{:ok, members}, state}
end
# EXPIRE is a no-op — tests don't need the TTL semantics, just the
# success ack.
defp run(["EXPIRE", _key, _ttl], state), do: {{:ok, 1}, state}
defp run(["DEL" | keys], state) do
{sets, removed} =
Enum.reduce(keys, {state.sets, 0}, fn k, {acc, count} ->
if Map.has_key?(acc, k),
do: {Map.delete(acc, k), count + 1},
else: {acc, count}
end)
{{:ok, removed}, %{state | sets: sets}}
end
defp run(["FLUSHDB"], _state), do: {{:ok, "OK"}, %{sets: %{}}}
defp run(_unknown, state), do: {{:error, :unsupported_command}, state}
end

View file

@ -23,15 +23,16 @@ defmodule Towerops.Security.FourOhFourTracker do
def record_404(ip_address, path) do
key = redis_key(ip_address)
conn = Towerops.Redix
redix = redix_module()
try do
# Add path to set
_ = Redix.command(conn, ["SADD", key, path])
_ = redix.command(conn, ["SADD", key, path])
# Set TTL if this is the first path
_ = Redix.command(conn, ["EXPIRE", key, @window_seconds])
_ = redix.command(conn, ["EXPIRE", key, @window_seconds])
# Get unique count
case Redix.command(conn, ["SCARD", key]) do
case redix.command(conn, ["SCARD", key]) do
{:ok, count} when count >= @threshold ->
Logger.warning("IP #{ip_address} hit threshold with #{count} unique 404s, triggering ban")
BruteForce.create_or_escalate_ban(ip_address)
@ -58,9 +59,10 @@ defmodule Towerops.Security.FourOhFourTracker do
def get_count(ip_address) do
key = redis_key(ip_address)
conn = Towerops.Redix
redix = redix_module()
try do
Result.unwrap_or(Redix.command(conn, ["SCARD", key]), 0)
Result.unwrap_or(redix.command(conn, ["SCARD", key]), 0)
catch
:exit, _ -> 0
end
@ -69,4 +71,8 @@ defmodule Towerops.Security.FourOhFourTracker do
defp redis_key(ip_address) do
"404_scan:#{ip_address}"
end
# Lets the test suite swap in `Towerops.Redix.Fake` so this module
# doesn't reach out to a real Redis from CI.
defp redix_module, do: Application.get_env(:towerops, :redix_module, Redix)
end

View file

@ -47,28 +47,13 @@ ExUnit.start()
# aren't installed (e.g. CI test gate runs in a lean image without
# gdal-bin). Locally we install GDAL via `brew install gdal` per
# CLAUDE.md, so the test runs there.
gdal_excludes =
extra_excludes =
if System.find_executable("gdal_translate") && System.find_executable("gdaldem") do
[]
else
[:requires_gdal]
end
# Skip Redis-backed tests when Redis isn't reachable on localhost:6379
# (e.g. the Forgejo CI test gate has no Redis service). Local dev runs
# Redis via direnv/services so the tests run there.
redis_excludes =
case :gen_tcp.connect(~c"127.0.0.1", 6379, [:binary, active: false], 250) do
{:ok, sock} ->
:gen_tcp.close(sock)
[]
{:error, _} ->
[:requires_redis]
end
extra_excludes = gdal_excludes ++ redis_excludes
# Exclude tests by default
# Run specific tests with: mix test --only <tag>
ExUnit.configure(

View file

@ -5,8 +5,6 @@ defmodule Towerops.RedisHealthCheckTest do
alias Towerops.RedisHealthCheck
@moduletag :requires_redis
describe "wait_for_redis/2" do
test "returns :ok when Redis is available" do
redis_config = Application.get_env(:towerops, :redis, host: "localhost", port: 6379)

View file

@ -1,56 +1,18 @@
defmodule Towerops.Security.FourOhFourTrackerTest do
use Towerops.DataCase
alias Towerops.Redix.Fake, as: RedixFake
alias Towerops.Security.FourOhFourTracker
@moduletag :four_oh_four_tracker
@moduletag :requires_redis
setup do
# Ensure a real Redix connection exists for these tests.
# The application may have started a stub Agent instead of real Redix.
ensure_redix_connection()
:ok
end
defp ensure_redix_connection do
case Process.whereis(Towerops.Redix) do
nil ->
start_real_redix()
pid ->
# Check if it's a real Redix process or a stub
try do
case Redix.command(pid, ["PING"]) do
{:ok, "PONG"} -> :ok
_ -> restart_as_real_redix()
end
catch
:exit, _ -> restart_as_real_redix()
end
end
end
defp restart_as_real_redix do
# In test mode `Towerops.Redix` is the in-process fake — wipe it so
# state doesn't leak between tests.
if pid = Process.whereis(Towerops.Redix) do
try do
Process.unregister(Towerops.Redix)
rescue
ArgumentError -> :ok
end
try do
GenServer.stop(pid, :normal, 100)
catch
:exit, _ -> :ok
end
RedixFake.reset(pid)
end
start_real_redix()
end
defp start_real_redix do
{:ok, _pid} = Redix.start_link(host: "localhost", port: 6379, name: Towerops.Redix)
:ok
end