7.4 KiB · text History b868907
defmodule GitGud.Packages.Signing.OpenBao.SessionTest do
  @moduledoc """
  How the signer gets a token, and how it keeps one.

  The interesting cases are the ones an operator only meets 32 days
  after a deploy: a lease that has to be renewed, a renewal that fails,
  and a login that can't happen because nothing is configured.
  """
  use ExUnit.Case, async: false

  alias GitGud.Packages.Signing.OpenBao
  alias GitGud.Packages.Signing.OpenBao.Session

  @address "http://openbao.test:8200"

  setup do
    on_exit(fn ->
      Application.delete_env(:git_gud, OpenBao)
      Application.delete_env(:git_gud, :openbao_req_options)
      Session.invalidate()
    end)

    # The session is a singleton the application starts, so each test
    # has to begin from "no token cached".
    Session.invalidate()
    :ok
  end

  describe "with nothing configured" do
    test "hands back no token and makes no request" do
      stub(fn _ -> raise "should not have called OpenBao" end)

      assert Session.token() == nil
    end

    test "an address with no credentials is still no token" do
      configure(address: @address)
      stub(fn _ -> raise "should not have called OpenBao" end)

      assert Session.token() == nil
    end
  end

  describe "with a static token" do
    test "asks for its lease, then reuses the token without asking again" do
      configure(address: @address, token: "bao-static")

      stub(fn
        {conn, "/v1/auth/token/lookup-self", _} -> json(conn, %{"data" => %{"ttl" => 2_764_800}})
        {_conn, path, _} -> raise "unexpected #{path}"
      end)

      assert Session.token() == "bao-static"
      assert_received {:openbao_request, "POST", "/v1/auth/token/lookup-self", _}

      # Cached: a second ask is free.
      assert Session.token() == "bao-static"
      refute_received {:openbao_request, "POST", "/v1/auth/token/lookup-self", _}
    end

    test "renews before the lease runs out, keeping the same token" do
      configure(address: @address, token: "bao-static")

      stub(fn
        {conn, "/v1/auth/token/lookup-self", _} ->
          json(conn, %{"data" => %{"ttl" => 60}})

        {conn, "/v1/auth/token/renew-self", _} ->
          json(conn, %{"auth" => %{"lease_duration" => 60}})

        {_conn, path, _} ->
          raise "unexpected #{path}"
      end)

      assert Session.token() == "bao-static"

      # Don't wait 40 seconds for the timer; ask for the renewal the
      # timer would have asked for.
      send(Process.whereis(Session), :renew)

      assert_receive {:openbao_request, "POST", "/v1/auth/token/renew-self", _}, 1_000
      assert Session.token() == "bao-static"
    end

    test "a failed renewal keeps the token it has rather than signing with nothing" do
      configure(address: @address, token: "bao-static")

      stub(fn
        {conn, "/v1/auth/token/lookup-self", _} -> json(conn, %{"data" => %{"ttl" => 600}})
        {conn, "/v1/auth/token/renew-self", _} -> error(conn, 403, "permission denied")
        {_conn, path, _} -> raise "unexpected #{path}"
      end)

      assert Session.token() == "bao-static"

      send(Process.whereis(Session), :renew)
      assert_receive {:openbao_request, "POST", "/v1/auth/token/renew-self", _}, 1_000

      # Still usable: the token is valid until its own expiry, and
      # refusing to sign early would be a worse failure than trying.
      assert Session.token() == "bao-static"
    end

    test "a token whose lease can't be read is used anyway" do
      configure(address: @address, token: "bao-static")

      stub(fn
        {conn, "/v1/auth/token/lookup-self", _} -> error(conn, 403, "permission denied")
        {_conn, path, _} -> raise "unexpected #{path}"
      end)

      assert Session.token() == "bao-static"
    end
  end

  describe "with Kubernetes auth" do
    setup do
      path = Path.join(System.tmp_dir!(), "sa-token-#{System.unique_integer([:positive])}")
      File.write!(path, "the-projected-jwt\n")
      on_exit(fn -> File.rm(path) end)
      %{jwt_path: path}
    end

    test "logs in with the pod's ServiceAccount JWT", ctx do
      configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path)

      stub(fn
        {conn, "/v1/auth/kubernetes/login", body} ->
          # The role and the JWT are the whole credential; a trailing
          # newline in the projected file would be rejected by OpenBao.
          assert body["role"] == "gitgud-fdroid"
          assert body["jwt"] == "the-projected-jwt"

          json(conn, %{"auth" => %{"client_token" => "bao-short-lived", "lease_duration" => 3600}})

        {_conn, path, _} ->
          raise "unexpected #{path}"
      end)

      assert Session.token() == "bao-short-lived"
      # No static token was needed anywhere.
      assert_received {:openbao_request, "POST", "/v1/auth/kubernetes/login", _}
    end

    test "a rejected login is reported as no token, not a stale one", ctx do
      configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path)

      stub(fn
        {conn, "/v1/auth/kubernetes/login", _} -> error(conn, 403, "role not found")
        {_conn, path, _} -> raise "unexpected #{path}"
      end)

      assert Session.token() == nil
    end

    test "a missing ServiceAccount token is a clear failure, not a crash", %{jwt_path: path} do
      File.rm!(path)
      configure(address: @address, role: "gitgud-fdroid", jwt_path: path)
      stub(fn _ -> raise "should not have reached the network" end)

      assert Session.token() == nil
    end

    test "a lease that runs out is replaced rather than handed out stale", ctx do
      configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path)

      stub(fn
        {conn, "/v1/auth/kubernetes/login", _} ->
          # Zero lease reads as "no expiry" — so use a 1-second one and
          # let it pass.
          json(conn, %{
            "auth" => %{
              "client_token" => "bao-#{System.unique_integer([:positive])}",
              "lease_duration" => 1
            }
          })

        # The renewal timer fires inside this window too; refusing it
        # is what forces the login path to be the one that recovers.
        {conn, "/v1/auth/token/renew-self", _} ->
          error(conn, 403, "not renewable")

        {_conn, path, _} ->
          raise "unexpected #{path}"
      end)

      first = Session.token()
      assert is_binary(first)

      Process.sleep(1_100)
      second = Session.token()

      assert is_binary(second)
      assert second != first
    end
  end

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

  defp configure(opts), do: Application.put_env(:git_gud, OpenBao, opts)

  defp stub(handler) when is_function(handler, 1) do
    test = self()

    Application.put_env(:git_gud, :openbao_req_options,
      plug: fn conn ->
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
        body = if raw == "", do: %{}, else: Jason.decode!(raw)
        send(test, {:openbao_request, conn.method, conn.request_path, body})
        handler.({conn, conn.request_path, body})
      end
    )
  end

  defp json(conn, payload) do
    conn
    |> Plug.Conn.put_resp_content_type("application/json")
    |> Plug.Conn.resp(200, Jason.encode!(payload))
  end

  defp error(conn, status, message) do
    conn
    |> Plug.Conn.put_resp_content_type("application/json")
    |> Plug.Conn.resp(status, Jason.encode!(%{"errors" => [message]}))
  end
end