diff --git a/config/test.exs b/config/test.exs index 5b00440d..cc6d85a7 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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" diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex index 9dd42bc1..e271ff90 100644 --- a/lib/towerops/application.ex +++ b/lib/towerops/application.ex @@ -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 diff --git a/lib/towerops/redis_health_check.ex b/lib/towerops/redis_health_check.ex index 0f38bd3d..2a7b6e7e 100644 --- a/lib/towerops/redis_health_check.ex +++ b/lib/towerops/redis_health_check.ex @@ -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 diff --git a/lib/towerops/redix/fake.ex b/lib/towerops/redix/fake.ex new file mode 100644 index 00000000..6d5e2f03 --- /dev/null +++ b/lib/towerops/redix/fake.ex @@ -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 diff --git a/lib/towerops/security/four_oh_four_tracker.ex b/lib/towerops/security/four_oh_four_tracker.ex index 0b09ed10..d4e5ec6f 100644 --- a/lib/towerops/security/four_oh_four_tracker.ex +++ b/lib/towerops/security/four_oh_four_tracker.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 27abfc49..9d0245ec 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -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 ExUnit.configure( diff --git a/test/towerops/redis_health_check_test.exs b/test/towerops/redis_health_check_test.exs index 743d3d8e..82f26597 100644 --- a/test/towerops/redis_health_check_test.exs +++ b/test/towerops/redis_health_check_test.exs @@ -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) diff --git a/test/towerops/security/four_oh_four_tracker_test.exs b/test/towerops/security/four_oh_four_tracker_test.exs index 07a9b36c..1e0441e1 100644 --- a/test/towerops/security/four_oh_four_tracker_test.exs +++ b/test/towerops/security/four_oh_four_tracker_test.exs @@ -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