test/openagents/forge/assignment_credential_auth_test.exs

58e6347eeb72 · 7 KB

defmodule OpenAgents.Forge.AssignmentCredentialAuthTest do
  @moduledoc """
  The credential an assignment mints must authenticate against the forge.

  This case exists because that had no coverage. The two existing tests of
  `Assignments.authenticate/1` assert only that an *invalid* token is refused,
  and every test that reaches `Assignments.create/1` wraps it in a `try` that
  tolerates the run failing to start, so none of them ever held a real
  plaintext. The branch-policy tests in `assignment_git_push_test.exs` built
  their own credential rows in the shape `authenticate/1` read rather than the
  shape `persist_assignment/7` writes.

  Between them, every test asserted something true about a credential no
  production assignment could produce, and a lookup against the wrong column
  shipped: the token carries the assignment id, the credential row carries its
  own autogenerated key, and `authenticate/1` asked for a credential whose
  primary key was the assignment id. That row cannot exist, so no assignment
  credential ever authenticated and every forge Git request from a Box run was
  refused `401`.

  The tests below go through the real minting path, so a regression puts that
  back.
  """

  use OpenAgents.DataCase, async: false

  import OpenAgents.AccountsFixtures

  alias OpenAgents.Box.ConversationBox
  alias OpenAgents.Conversations
  alias OpenAgents.Forge.{AssignmentCredential, Assignments}
  alias OpenAgents.Issues
  alias OpenAgents.Repo

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

    original_api = Application.get_env(:openagents, :box_api)
    original_key = Application.get_env(:openagents, :box_api_key)

    # The provider is stubbed so `start_target/7` succeeds and `create/1`
    # returns the plaintext.
    Application.put_env(:openagents, :box_api,
      base_url: "https://box-api.internal",
      request_options: [plug: &stub_provider/1]
    )

    Application.put_env(:openagents, :box_api_key, "assignment-credential-auth-test")

    on_exit(fn ->
      restore(:box_api, original_api)
      restore(:box_api_key, original_key)
    end)

    :ok
  end

  describe "a credential that an assignment minted" do
    test "authenticates and resolves its own assignment, repository, and branch" do
      %{assignment: assignment, plaintext: plaintext, repository: repository, branch: branch} =
        admit("credential-auth-owner", "bx_credaut2")

      assert {:ok, principal} = Assignments.authenticate(plaintext)

      assert principal.kind == :assignment
      assert principal.assignment_id == assignment.id
      assert principal.id == assignment.id
      assert principal.repository_id == repository.id
      assert principal.branch == branch
    end

    test "is reached through assignment_id, not through the credential's own key" do
      %{assignment: assignment, plaintext: plaintext} =
        admit("credential-auth-shape", "bx_credaut3")

      "oa_assignment_" <> rest = plaintext
      [token_uuid, _secret] = String.split(rest, ".", parts: 2)

      # The token names the assignment. The credential row has an unrelated
      # primary key. Both facts have to hold for the lookup under test to be
      # the right one.
      assert token_uuid == assignment.id
      assert %AssignmentCredential{} = credential = Assignments.credential(assignment)
      assert credential.assignment_id == assignment.id
      refute credential.id == assignment.id

      assert {:ok, principal} = Assignments.authenticate(plaintext)
      assert principal.credential_id == credential.id
    end
  end

  describe "the check is no looser than it was" do
    test "a malformed token is refused" do
      assert {:error, :invalid_assignment_credential} =
               Assignments.authenticate("oa_assignment_not-a-credential")

      assert {:error, :invalid_assignment_credential} = Assignments.authenticate("oa_assignment_")
      assert {:error, :invalid_assignment_credential} = Assignments.authenticate("nonsense")
    end

    test "a well-formed token naming another assignment is refused" do
      %{assignment: assignment} = admit("credential-auth-foreign-a", "bx_credaut4")
      %{plaintext: other_plaintext} = admit("credential-auth-foreign-b", "bx_credaut5")

      "oa_assignment_" <> rest = other_plaintext
      [_other_uuid, other_secret] = String.split(rest, ".", parts: 2)

      # This assignment's id, another assignment's secret. The row is found and
      # the digest comparison is what refuses it.
      forged = "oa_assignment_" <> assignment.id <> "." <> other_secret

      assert {:error, :invalid_assignment_credential} = Assignments.authenticate(forged)
    end

    test "a revoked credential is refused" do
      %{assignment: assignment, plaintext: plaintext} =
        admit("credential-auth-revoked", "bx_credaut6")

      assert {:ok, _principal} = Assignments.authenticate(plaintext)

      assignment
      |> Assignments.credential()
      |> AssignmentCredential.changeset(%{revoked_at: DateTime.utc_now()})
      |> Repo.update!()

      assert {:error, :invalid_assignment_credential} = Assignments.authenticate(plaintext)
    end

    test "an expired credential is refused" do
      %{assignment: assignment, plaintext: plaintext} =
        admit("credential-auth-expired", "bx_credaut7")

      assignment
      |> Assignments.credential()
      |> AssignmentCredential.changeset(%{
        expires_at: DateTime.add(DateTime.utc_now(), -1, :second)
      })
      |> Repo.update!()

      assert {:error, :invalid_assignment_credential} = Assignments.authenticate(plaintext)
    end
  end

  defp admit(owner_slug, box_id) do
    owner = repository_user_fixture(owner_slug)
    repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")

    {:ok, issue} =
      Issues.create_issue(repository, %{title: "Prove the credential", body: "A body."})

    {:ok, conversation} = Conversations.ensure_conversation(owner)

    {:ok, box} =
      %ConversationBox{}
      |> ConversationBox.changeset(%{
        conversation_id: conversation.id,
        box_id: box_id,
        state: "ready",
        setup_status: "done"
      })
      |> Repo.insert()

    branch = "agent/issue-#{issue.number}"

    assert {:ok, assignment, plaintext} =
             Assignments.create(%{
               "target_kind" => "box",
               "box_id" => box.box_id,
               "conversation_id" => conversation.id,
               "repository_id" => repository.id,
               "issue_number" => issue.number,
               "branch" => branch,
               "requesting_user" => owner,
               "requesting_principal" => owner
             })

    %{
      assignment: assignment,
      plaintext: plaintext,
      repository: repository,
      branch: branch,
      owner: owner
    }
  end

  # The run exists only so `create/1` reaches its return. It used to park the
  # worker on a 30-second sleep, because `start_target/7` read the assignment's
  # state and then wrote `running` back over whatever a finalizing run had
  # written in between — so a stubbed run that reached a terminal state revoked
  # the credential this case had just minted. The guard and the write are one
  # statement now (issue #257), so the worker runs at its own speed here.
  defp stub_provider(conn) do
    {:ok, raw, conn} = Plug.Conn.read_body(conn)

    case Jason.decode(raw) do
      {:ok, %{"command" => command}} when is_binary(command) ->
        Req.Test.json(conn, %{"stdout" => "4242\n"})

      _other ->
        Req.Test.json(conn, %{
          "box" => %{"id" => "bx_credaut2", "state" => "ready", "setupStatus" => "done"}
        })
    end
  end

  defp restore(key, nil), do: Application.delete_env(:openagents, key)
  defp restore(key, value), do: Application.put_env(:openagents, key, value)
end