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