test/openagents/forge/assignment_git_push_test.exs

main at 58e6347eeb72 · 9 KB

defmodule OpenAgents.Forge.AssignmentGitPushTest do
  @moduledoc """
  #234: branch-scoped assignment credentials enforce ref-level limits over the
  real git HTTP path.
  """

  use OpenAgents.DataCase, async: false

  import OpenAgents.AccountsFixtures

  alias OpenAgents.Repo
  alias OpenAgents.Forge.{Assignment, AssignmentCredential, CacheReadiness}
  alias OpenAgents.Box.ConversationBox

  defmodule TestPipeline do
    @moduledoc false
    use Plug.Builder

    plug OpenAgentsWeb.Plugs.ForgeGitAuth
    plug OpenAgents.Forge.GitHTTP
  end

  setup do
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})

    base = Path.join(System.tmp_dir!(), "assignment-git-#{System.unique_integer([:positive])}")
    File.mkdir_p!(base)

    previous_data = Application.get_env(:openagents, :forge_data_dir)
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
    CacheReadiness.reset()

    user = repository_user_fixture("assignment-pusher")

    {:ok, repository, :created} =
      OpenAgents.Repositories.create_user_repository(
        user,
        %{name: "branch-scoped"},
        "branch-scoped-#{System.unique_integer([:positive])}"
      )

    repository =
      repository
      |> Ecto.Changeset.change(
        lifecycle_state: "ready",
        ready_at: DateTime.utc_now(),
        default_branch: "develop",
        protected_branches: ["agent/protected-1"]
      )
      |> Repo.update!()

    {:ok, conversation} =
      OpenAgents.Conversations.ensure_conversation(
        "assignment-git-#{System.unique_integer([:positive])}"
      )

    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)

    box = fn id ->
      %ConversationBox{}
      |> ConversationBox.changeset(%{
        conversation_id: conversation.id,
        box_id: "bx_#{id}_#{System.unique_integer([:positive])}",
        state: "ready",
        setup_status: "done"
      })
      |> Repo.insert!()
    end

    assigned_issue = issue!(repository, "Assigned branch issue")
    assigned_box = box.("assigned")

    assigned_assignment =
      %Assignment{}
      |> Assignment.changeset(%{
        conversation_box_id: assigned_box.id,
        repository_id: repository.id,
        issue_id: assigned_issue.id,
        requesting_principal: %{
          "type" => "user",
          "id" => user.id,
          "actor_type" => "user",
          "actor_id" => user.id
        },
        branch: "agent/issue-1",
        deadline_at: DateTime.add(now, 3600, :second),
        admitted_at: now
      })
      |> Repo.insert!()

    {assigned_token, _assigned_credential} =
      create_credential(assigned_assignment, repository, "agent/issue-1")

    protected_issue = issue!(repository, "Protected branch issue")
    protected_box = box.("protected")

    protected_assignment =
      %Assignment{}
      |> Assignment.changeset(%{
        conversation_box_id: protected_box.id,
        repository_id: repository.id,
        issue_id: protected_issue.id,
        requesting_principal: %{
          "type" => "user",
          "id" => user.id,
          "actor_type" => "user",
          "actor_id" => user.id
        },
        branch: "agent/protected-1",
        deadline_at: DateTime.add(now, 3600, :second),
        admitted_at: now
      })
      |> Repo.insert!()

    {protected_token, _protected_credential} =
      create_credential(protected_assignment, repository, "agent/protected-1")

    default_issue = issue!(repository, "Default branch issue")
    default_box = box.("default")

    default_assignment =
      %Assignment{}
      |> Assignment.changeset(%{
        conversation_box_id: default_box.id,
        repository_id: repository.id,
        issue_id: default_issue.id,
        requesting_principal: %{
          "type" => "user",
          "id" => user.id,
          "actor_type" => "user",
          "actor_id" => user.id
        },
        branch: repository.default_branch,
        deadline_at: DateTime.add(now, 3600, :second),
        admitted_at: now
      })
      |> Repo.insert!()

    {default_branch_token, _default_credential} =
      create_credential(default_assignment, repository, repository.default_branch)

    port = free_port()
    start_supervised!({Bandit, plug: TestPipeline, port: port, ip: {127, 0, 0, 1}})

    on_exit(fn ->
      if previous_data,
        do: Application.put_env(:openagents, :forge_data_dir, previous_data),
        else: Application.delete_env(:openagents, :forge_data_dir)

      if previous_wal,
        do: Application.put_env(:openagents, :forge_wal_dir, previous_wal),
        else: Application.delete_env(:openagents, :forge_wal_dir)

      CacheReadiness.reset()
      File.rm_rf(base)
    end)

    work =
      seed_clone!(
        base,
        "http://x:#{assigned_token}@127.0.0.1:#{port}/assignment-pusher/branch-scoped.git"
      )

    %{
      work: work,
      repository: repository,
      port: port,
      assigned_token: assigned_token,
      protected_token: protected_token,
      default_branch_token: default_branch_token
    }
  end

  describe "branch-scoped assignment credentials over git-receive-pack" do
    test "pushing to the repository default branch with the assigned credential is refused",
         context do
      %{work: work, port: port, assigned_token: assigned_token} = context
      commit!(work, "Default branch commit")

      url =
        "http://x:#{assigned_token}@127.0.0.1:#{port}/assignment-pusher/branch-scoped.git"

      {_output, status} = git(work, ["push", url, "HEAD:develop"])
      assert status != 0
    end

    test "pushing to the assigned branch with the assigned credential succeeds", context do
      %{work: work} = context
      commit!(work, "Assigned branch commit")
      git!(work, ["push", "origin", "HEAD:agent/issue-1"])
    end

    test "pushing to another non-assigned branch with the assigned credential is refused",
         context do
      %{work: work} = context
      commit!(work, "Other branch commit")
      {_output, status} = git(work, ["push", "origin", "HEAD:agent/other"])
      assert status != 0
    end

    test "pushing to a protected branch is refused", context do
      %{work: work, port: port, protected_token: protected_token} = context
      commit!(work, "Protected branch commit")

      url =
        "http://x:#{protected_token}@127.0.0.1:#{port}/assignment-pusher/branch-scoped.git"

      {_output, status} = git(work, ["push", url, "HEAD:agent/protected-1"])
      assert status != 0
    end

    test "a credential for the default branch itself is refused", context do
      %{
        work: work,
        port: port,
        repository: repository,
        default_branch_token: default_branch_token
      } =
        context

      commit!(work, "Default branch credential commit")

      url =
        "http://x:#{default_branch_token}@127.0.0.1:#{port}/assignment-pusher/branch-scoped.git"

      {_output, status} = git(work, ["push", url, "HEAD:#{repository.default_branch}"])
      assert status != 0
    end
  end

  # Mints the way `Assignments.persist_assignment/7` mints: the token carries
  # the *assignment* id and the credential row takes its own autogenerated key.
  #
  # This helper used to set `%AssignmentCredential{id: credential_id}` and
  # embed that credential id in the token, which is the shape `authenticate/1`
  # read but not the shape production wrote. The branch-policy tests below
  # therefore passed against a credential no real assignment could produce,
  # and a credential that could never authenticate shipped underneath them.
  defp create_credential(assignment, repository, branch) do
    secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
    plaintext = "oa_assignment_" <> assignment.id <> "." <> secret
    digest = :crypto.hash(:sha256, plaintext)

    credential =
      %AssignmentCredential{}
      |> AssignmentCredential.changeset(%{
        assignment_id: assignment.id,
        token_digest: digest,
        last_four: String.slice(secret, -4, 4),
        repository_id: repository.id,
        branch: branch,
        expires_at: DateTime.add(DateTime.utc_now(), 3600, :second)
      })
      |> Repo.insert!()

    {plaintext, credential}
  end

  defp free_port do
    {:ok, socket} = :gen_tcp.listen(0, [])
    {:ok, port} = :inet.port(socket)
    :gen_tcp.close(socket)
    port
  end

  defp git!(dir, args) do
    {output, status} =
      System.cmd("git", ["-c", "credential.helper="] ++ args,
        cd: dir,
        stderr_to_stdout: true,
        env: [{"GIT_TERMINAL_PROMPT", "0"}]
      )

    if status != 0, do: flunk("git #{Enum.join(args, " ")} failed:\n#{output}")
    output
  end

  defp git(dir, args) do
    System.cmd("git", ["-c", "credential.helper="] ++ args,
      cd: dir,
      stderr_to_stdout: true,
      env: [{"GIT_TERMINAL_PROMPT", "0"}]
    )
  end

  defp seed_clone!(base, url) do
    work = Path.join(base, "work")
    git!(base, ["clone", url, work])
    git!(work, ["config", "user.email", "assignment@example.com"])
    git!(work, ["config", "user.name", "Assignment"])
    git!(work, ["symbolic-ref", "HEAD", "refs/heads/main"])
    work
  end

  defp commit!(work, message) do
    name = "file-#{System.unique_integer([:positive])}.txt"
    File.write!(Path.join(work, name), message)
    git!(work, ["add", "-A"])
    git!(work, ["commit", "-m", message])
    work |> git!(["rev-parse", "HEAD"]) |> String.trim()
  end

  defp issue!(repository, title) do
    {:ok, issue} = OpenAgents.Issues.create_issue(repository, %{title: title})
    issue
  end
end