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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.4 KiB · text
History
b868907