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:
parent
670516f641
commit
67d8be6b89
8 changed files with 169 additions and 85 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -214,7 +214,13 @@ defmodule Towerops.Application do
|
|||
defp redis_spec do
|
||||
redis_config = Application.get_env(:towerops, :redis, [])
|
||||
|
||||
if Keyword.has_key?(redis_config, :host) do
|
||||
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}
|
||||
|
||||
Keyword.has_key?(redis_config, :host) ->
|
||||
opts = [
|
||||
host: Keyword.get(redis_config, :host, "localhost"),
|
||||
port: Keyword.get(redis_config, :port, 6379),
|
||||
|
|
@ -230,7 +236,8 @@ defmodule Towerops.Application do
|
|||
end
|
||||
|
||||
{Redix, opts}
|
||||
else
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
116
lib/towerops/redix/fake.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
RedixFake.reset(pid)
|
||||
end
|
||||
|
||||
try do
|
||||
GenServer.stop(pid, :normal, 100)
|
||||
catch
|
||||
:exit, _ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
start_real_redix()
|
||||
end
|
||||
|
||||
defp start_real_redix do
|
||||
{:ok, _pid} = Redix.start_link(host: "localhost", port: 6379, name: Towerops.Redix)
|
||||
:ok
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue