7.6 KiB · text History b868907
defmodule GitGudWeb.GitJobTokenTest do
  @moduledoc """
  A CI job's token over git smart HTTP: what `permissions: contents:`
  lets it do to its own repository, that it reaches no other, that it
  stops working when its job is over, and that a push made with it is
  marked so it starts no workflows.

  The push goes through `git http-backend` for real; the repository's
  hooks are pointed at stand-ins (`GITGUD_PRE_RECEIVE_BIN` /
  `GITGUD_POST_RECEIVE_BIN`, which the installed hooks honour) because
  the real ones call back into a running server. Branch protection is
  checked through the endpoint the real pre-receive hook calls.
  """
  use GitGudWeb.ConnCase, async: false

  import GitGud.ForgeFixtures

  alias GitGud.BranchProtections
  alias GitGud.Repositories.Storage
  alias GitGud.Workflows.RunnerJwt
  alias GitGud.Workflows.WorkflowJob
  alias GitGud.Workflows.WorkflowRun

  @moduletag :git_required

  @zero String.duplicate("0", 40)

  setup do
    if System.find_executable("git") == nil do
      {:skip, "git not on PATH"}
    else
      {user, repo} = repository_fixture(%{visibility: "private"})
      {:ok, user: user, repo: repo, path: "/#{Storage.owner_handle(user)}/#{repo.name}.git"}
    end
  end

  defp job_token(repo, perms, opts \\ []) do
    run =
      %WorkflowRun{repository_id: repo.id}
      |> WorkflowRun.create_changeset(%{
        number: System.unique_integer([:positive]),
        workflow_path: ".forgejo/workflows/ci.yml",
        trigger: "push",
        head_sha: :crypto.strong_rand_bytes(20),
        triggered_by_id: opts[:triggered_by_id]
      })
      |> GitGud.Repo.insert!()

    job =
      %WorkflowJob{workflow_run_id: run.id}
      |> WorkflowJob.create_changeset(%{job_id: "build", definition: %{}})
      |> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
      |> GitGud.Repo.insert!()

    {job, RunnerJwt.mint(job, run, perms)}
  end

  defp perms(contents), do: %{"contents" => contents, "packages" => :write, "actions" => :write}

  defp basic(token), do: "Basic " <> Base.encode64("x-access-token:" <> token)

  defp info_refs(path, service, token) do
    build_conn()
    |> put_req_header("authorization", basic(token))
    |> get(path <> "/info/refs?service=" <> service)
  end

  describe "contents: read (the default)" do
    test "fetches its own repository and can't push to it", %{repo: repo, path: path} do
      {_job, token} = job_token(repo, perms(:read))

      assert info_refs(path, "git-upload-pack", token).status == 200
      assert info_refs(path, "git-receive-pack", token).status == 403
    end
  end

  describe "contents: none" do
    test "can't even fetch", %{repo: repo, path: path} do
      {_job, token} = job_token(repo, perms(:none))
      assert info_refs(path, "git-upload-pack", token).status == 403
    end
  end

  describe "contents: write" do
    test "may push to its own repository, not to another", %{repo: repo, path: path} do
      {_job, token} = job_token(repo, perms(:write))
      assert info_refs(path, "git-receive-pack", token).status == 200

      {other_user, other} = repository_fixture(%{visibility: "private"})
      other_path = "/#{Storage.owner_handle(other_user)}/#{other.name}.git"

      assert info_refs(other_path, "git-receive-pack", token).status == 403
      assert info_refs(other_path, "git-upload-pack", token).status == 403
    end

    test "a push lands, attributed to the run's author and marked as the job's", %{
      user: user,
      repo: repo,
      path: path
    } do
      {job, token} = job_token(repo, perms(:write), triggered_by_id: user.id)
      env_file = stub_hooks!()

      {sha, pack} = commit_pack!()

      conn =
        build_conn()
        |> put_req_header("authorization", basic(token))
        |> put_req_header("content-type", "application/x-git-receive-pack-request")
        |> post(
          path <> "/git-receive-pack",
          receive_pack_request(@zero, sha, "refs/heads/main", pack)
        )

      assert conn.status == 200
      assert conn.resp_body =~ "ok refs/heads/main"

      {head, 0} = System.cmd("git", ["-C", repo.disk_path, "rev-parse", "refs/heads/main"])
      assert String.trim(head) == sha

      hook_env = File.read!(env_file)
      assert hook_env =~ "GITGUD_JOB_ID=#{job.id}\n"
      assert hook_env =~ "GITGUD_PUSHER_ID=#{user.id}\n"
      # And the secret its call back into /_hooks needs.
      assert hook_env =~ "GITGUD_HOOK_SECRET=#{GitGudWeb.Plugs.HookAuth.secret()}\n"
    end

    test "branch protection judges it like any direct push", %{repo: repo} do
      {:ok, _} =
        BranchProtections.create_rule(repo, %{"pattern" => "main", "required_approvals" => 1})

      {sha, _pack} = commit_pack!()

      conn =
        build_conn()
        |> put_req_header("x-repo-path", repo.disk_path)
        |> put_req_header("content-type", "text/plain")
        |> put_req_header("x-gitgud-hook-token", GitGudWeb.Plugs.HookAuth.secret())
        |> post("/_hooks/pre-receive", "#{@zero} #{sha} refs/heads/main\n")

      assert "DENY\n" <> reason = conn.resp_body
      assert reason =~ "approvals"
    end
  end

  test "a finished job's token is refused", %{repo: repo, path: path} do
    {job, token} = job_token(repo, perms(:write))

    job
    |> Ecto.Changeset.change(
      status: "success",
      completed_at: DateTime.add(DateTime.utc_now(:second), -600, :second)
    )
    |> GitGud.Repo.update!()

    assert info_refs(path, "git-upload-pack", token).status == 401
  end

  # Point the repository's hooks at stand-ins: pre-receive allows, and
  # post-receive records the environment the transport gave it.
  defp stub_hooks! do
    dir = Path.join(System.tmp_dir!(), "gitgud-hooks-#{System.unique_integer([:positive])}")
    File.mkdir_p!(dir)
    env_file = Path.join(dir, "post-receive.env")
    post = Path.join(dir, "post-receive")

    File.write!(post, """
    #!/bin/sh
    cat >/dev/null
    env | grep '^GITGUD_' > '#{env_file}'
    """)

    File.chmod!(post, 0o755)

    previous =
      {System.get_env("GITGUD_PRE_RECEIVE_BIN"), System.get_env("GITGUD_POST_RECEIVE_BIN")}

    System.put_env("GITGUD_PRE_RECEIVE_BIN", System.find_executable("true"))
    System.put_env("GITGUD_POST_RECEIVE_BIN", post)

    on_exit(fn ->
      restore_env("GITGUD_PRE_RECEIVE_BIN", elem(previous, 0))
      restore_env("GITGUD_POST_RECEIVE_BIN", elem(previous, 1))
      File.rm_rf!(dir)
    end)

    env_file
  end

  defp restore_env(name, nil), do: System.delete_env(name)
  defp restore_env(name, value), do: System.put_env(name, value)

  # One commit in a scratch repository, and a pack of everything in it.
  defp commit_pack! do
    dir = Path.join(System.tmp_dir!(), "gitgud-src-#{System.unique_integer([:positive])}")
    on_exit(fn -> File.rm_rf!(dir) end)
    git = fn args -> System.cmd("git", ["-C", dir | args], stderr_to_stdout: true) end

    {_, 0} = System.cmd("git", ["init", "-q", dir])
    File.write!(Path.join(dir, "README"), "hello\n")
    {_, 0} = git.(["add", "README"])

    {_, 0} =
      git.(["-c", "user.name=ci", "-c", "user.email=ci@example.com", "commit", "-q", "-m", "c"])

    {sha, 0} = git.(["rev-parse", "HEAD"])
    sha = String.trim(sha)

    list = Path.join(dir, ".git/pack-list")
    File.write!(list, sha <> "\n")

    {pack, 0} =
      System.cmd("sh", ["-c", "git -C '#{dir}' pack-objects --revs --stdout -q < '#{list}'"])

    {sha, pack}
  end

  defp receive_pack_request(old, new, ref, pack) do
    pkt_line("#{old} #{new} #{ref}\0report-status\n") <> "0000" <> pack
  end

  defp pkt_line(data) do
    (byte_size(data) + 4)
    |> Integer.to_string(16)
    |> String.downcase()
    |> String.pad_leading(4, "0")
    |> Kernel.<>(data)
  end
end