4.9 KiB · text History 3d64eb5
defmodule GitGudWeb.PrereceiveScriptTest do
  @moduledoc """
  `priv/scripts/gitgud-prereceive`, run for real against a stand-in
  backend: a socket that answers whatever status this test says.

  What it covers is when `GITGUD_PREHOOK_FAIL_OPEN=1` may let a push
  through. Only when the backend can't be reached (or fails with 5xx) —
  never on an answer it gave: a 403 from a missing or wrong hook secret
  used to read as "unreachable" (`curl -f`), so with fail-open on, a
  misconfigured install pushed straight past branch protection.
  """
  use ExUnit.Case, async: true

  @script Path.expand("../../priv/scripts/gitgud-prereceive", __DIR__)
  @zero String.duplicate("0", 40)
  @stdin "#{@zero} #{String.duplicate("a", 40)} refs/heads/main\n"

  setup do
    cond do
      System.find_executable("curl") == nil -> {:skip, "curl not on PATH"}
      System.find_executable("sh") == nil -> {:skip, "sh not on PATH"}
      true -> :ok
    end
  end

  describe "the backend answers" do
    test "OK lets the push through" do
      url = serve(200, "OK\n\n")
      assert {_, 0} = run(url)
    end

    test "DENY refuses it, with the reason" do
      url = serve(200, "DENY\nrefs/heads/main: force-push blocked by branch protection\n")
      assert {out, 1} = run(url)
      assert out =~ "force-push blocked"
    end

    test "a 403 refuses it, fail-open or not" do
      url = serve(403, "DENY forbidden\n")
      assert {out, 1} = run(url, %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
      assert out =~ "403"
      refute out =~ "allowing push"
    end

    test "a 404 refuses it, fail-open or not" do
      url = serve(404, "unknown repo\n")
      assert {_, 1} = run(url, %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
    end

    test "a 500 refuses it, unless fail-open is on" do
      assert {_, 1} = run(serve(500, "boom"))
      assert {out, 0} = run(serve(500, "boom"), %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
      assert out =~ "WITHOUT branch protection"
    end
  end

  describe "the backend can't be reached" do
    test "refuses the push, unless fail-open is on" do
      url = closed_url()
      assert {out, 1} = run(url)
      assert out =~ "unreachable"
      assert {_, 0} = run(url, %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
    end
  end

  test "with no hook secret it refuses without asking, fail-open or not" do
    url = closed_url()
    assert {out, 1} = run(url, %{"GITGUD_HOOK_SECRET" => "", "GITGUD_PREHOOK_FAIL_OPEN" => "1"})
    assert out =~ "GITGUD_HOOK_SECRET"
  end

  # ── helpers ─────────────────────────────────────────────────────────

  defp run(url, env \\ %{}) do
    dir = Path.join(System.tmp_dir!(), "gitgud-prerecv-#{System.unique_integer([:positive])}")
    File.mkdir_p!(dir)
    on_exit(fn -> File.rm_rf!(dir) end)

    stdin = Path.join(dir, "stdin")
    File.write!(stdin, @stdin)

    env =
      Map.merge(
        %{
          "GITGUD_PREHOOK_URL" => url,
          "GITGUD_HOOK_SECRET" => "a-secret-of-some-length",
          "GITGUD_PREHOOK_FAIL_OPEN" => "",
          "GIT_DIR" => dir
        },
        env
      )

    System.cmd("sh", ["-c", ~s(exec "$0" < "$1"), @script, stdin],
      env: Enum.to_list(env),
      stderr_to_stdout: true
    )
  end

  # A one-shot HTTP server on a free port: reads the request (headers,
  # then content-length bytes of body) and answers `status` with `body`.
  defp serve(status, body) do
    {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true])
    {:ok, port} = :inet.port(listen)

    pid =
      spawn_link(fn ->
        {:ok, sock} = :gen_tcp.accept(listen, 10_000)
        _ = read_request(sock, "")

        :ok =
          :gen_tcp.send(
            sock,
            "HTTP/1.1 #{status} X\r\ncontent-length: #{byte_size(body)}\r\n" <>
              "connection: close\r\n\r\n" <> body
          )

        :gen_tcp.close(sock)
        :gen_tcp.close(listen)
      end)

    on_exit(fn -> Process.exit(pid, :kill) end)
    "http://127.0.0.1:#{port}/_hooks/pre-receive"
  end

  defp read_request(sock, acc) do
    case :binary.split(acc, "\r\n\r\n") do
      [head, rest] ->
        len =
          case Regex.run(~r/content-length:\s*(\d+)/i, head) do
            [_, n] -> String.to_integer(n)
            _ -> 0
          end

        read_body(sock, rest, len)

      [_] ->
        {:ok, data} = :gen_tcp.recv(sock, 0, 10_000)
        read_request(sock, acc <> data)
    end
  end

  defp read_body(_sock, body, len) when byte_size(body) >= len, do: body

  defp read_body(sock, body, len) do
    {:ok, data} = :gen_tcp.recv(sock, 0, 10_000)
    read_body(sock, body <> data, len)
  end

  # A port nothing listens on.
  defp closed_url do
    {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false])
    {:ok, port} = :inet.port(listen)
    :gen_tcp.close(listen)
    "http://127.0.0.1:#{port}/_hooks/pre-receive"
  end
end