test/openagents/tools/publish_changes_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Tools.PublishChangesTest do
  use OpenAgents.DataCase, async: false

  alias OpenAgents.Tools.{
    ExecutionContext,
    PublishChanges,
    Registry,
    Runner,
    WorkspacePublication
  }

  setup do
    base = Path.join(System.tmp_dir!(), "publish-changes-#{System.unique_integer([:positive])}")
    root = Path.join(base, "workspace")
    remote = Path.join(base, "remote.git")
    File.mkdir_p!(root)

    git!(root, ["init", "-b", "main"])
    File.write!(Path.join(root, "README.md"), "initial\n")
    git!(root, ["add", "README.md"])

    git!(root, [
      "-c",
      "user.name=Fixture",
      "-c",
      "user.email=fixture@example.com",
      "commit",
      "-m",
      "Initial"
    ])

    System.cmd("git", ["clone", "--bare", root, remote]) |> successful!()

    user = repository_user_fixture("publisher")
    repository = repository_with_member_fixture(user, %{owner: "OpenAgentsInc"})

    previous_url = Application.get_env(:openagents, :workspace_publish_url_resolver)
    previous_receipt = Application.get_env(:openagents, :workspace_publish_receipt_resolver)
    Application.put_env(:openagents, :workspace_publish_url_resolver, fn _repo -> remote end)

    Application.put_env(:openagents, :workspace_publish_receipt_resolver, fn _repo, branch, oid ->
      {:ok,
       %{
         "schema" => "openagents.forge_push_receipt.v1",
         "id" => "receipt-test",
         "wal_seq" => 17,
         "ref" => "refs/heads/#{branch}",
         "oid" => oid
       }}
    end)

    on_exit(fn ->
      restore(:workspace_publish_url_resolver, previous_url)
      restore(:workspace_publish_receipt_resolver, previous_receipt)
      File.rm_rf(base)
    end)

    context = %ExecutionContext{
      scope: "browser_conversation",
      scope_ref: "conversation:publish",
      authorities: MapSet.new(["repository.write"]),
      owner_user_id: user.id,
      conversation_id: Ecto.UUID.generate(),
      workspace: %{
        "type" => "repository_workspace",
        "root" => root,
        "canonical" => false,
        "read_only" => false,
        "workspace_ref" => "workspace:publish",
        "repository_id" => repository.id
      }
    }

    %{context: context, root: root, remote: remote, repository: repository}
  end

  test "publishes all changes to only the server-assigned branch and returns receipts", %{
    context: context,
    root: root,
    remote: remote,
    repository: repository
  } do
    File.write!(Path.join(root, "README.md"), "changed\n")
    File.write!(Path.join(root, "new.txt"), "new\n")
    assert {:ok, digest} = WorkspacePublication.workspace_digest(context)

    assert {:ok, execution} =
             PublishChanges.execute(
               %{"message" => "Publish workspace", "expected_workspace_digest" => digest},
               context
             )

    result = execution.result
    branch = WorkspacePublication.branch(context)
    assert result["branch"] == branch
    assert result["repository"] == "#{repository.namespace.slug}/#{repository.name}"
    assert result["summary"] == %{"files_changed" => 2, "insertions" => 2, "deletions" => 1}
    assert result["receipt"]["wal_seq"] == 17
    assert Enum.any?(execution.target_receipt_refs, &String.starts_with?(&1, "forge-push:"))

    published = ls_remote!(remote, branch)
    assert published == result["published_oid"]
    assert ls_remote!(remote, "main") == result["source_oid"]
    assert git!(root, ["status", "--porcelain"]) =~ "README.md"
  end

  test "retries are deterministic and do not create a second commit", %{
    context: context,
    root: root
  } do
    File.write!(Path.join(root, "README.md"), "changed\n")
    assert {:ok, first} = WorkspacePublication.publish(context, "Retry-safe publication", nil)
    assert {:ok, second} = WorkspacePublication.publish(context, "Retry-safe publication", nil)
    assert first == second
  end

  test "refuses stale, empty, read-only, unbound, and unauthorized workspaces", %{
    context: context,
    root: root
  } do
    assert {:error, :nothing_to_publish} =
             WorkspacePublication.publish(context, "No changes", nil)

    File.write!(Path.join(root, "README.md"), "changed\n")

    assert {:error, :stale_workspace_digest} =
             WorkspacePublication.publish(context, "Stale", String.duplicate("0", 64))

    assert {:error, :workspace_read_only} =
             WorkspacePublication.publish(
               put_in(context.workspace["read_only"], true),
               "Read only",
               nil
             )

    assert {:error, :repository_workspace_unavailable} =
             WorkspacePublication.publish(%{context | workspace: nil}, "Unbound", nil)

    stranger = repository_user_fixture("stranger")

    assert {:error, :repository_write_refused} =
             WorkspacePublication.publish(
               %{context | owner_user_id: stranger.id},
               "No access",
               nil
             )
  end

  test "runner requires authority and an exact publication approval", %{
    context: context,
    root: root
  } do
    File.write!(Path.join(root, "README.md"), "approved\n")
    assert {:ok, snapshot} = Registry.build([PublishChanges])

    call = %{
      call_id: "call-publish",
      name: "publish_changes",
      version: 1,
      raw_arguments: Jason.encode!(%{"message" => "Approved publication"})
    }

    assert {:ok, no_approval} = Runner.run(snapshot, call, context)
    assert no_approval["error"]["code"] == "module_approval_required"

    receipt = WorkspacePublication.approval_receipt(context.scope_ref, "approval:publish")

    assert {:ok, no_authority} =
             Runner.run(snapshot, call, %{
               context
               | authorities: MapSet.new(),
                 approval_receipts: [receipt]
             })

    assert no_authority["error"]["code"] == "authority_refused"

    assert {:ok, published} =
             Runner.run(snapshot, call, %{context | approval_receipts: [receipt]})

    assert published["status"] == "succeeded"
    assert published["result"]["branch"] == WorkspacePublication.branch(context)
  end

  test "the model cannot supply a repository, remote, or branch", %{context: context, root: root} do
    File.write!(Path.join(root, "README.md"), "changed\n")
    assert {:ok, snapshot} = Registry.build([PublishChanges])
    receipt = WorkspacePublication.approval_receipt(context.scope_ref, "approval:publish")

    for forbidden <- ["repository", "remote", "branch"] do
      call = %{
        call_id: "call-#{forbidden}",
        name: "publish_changes",
        version: 1,
        raw_arguments: Jason.encode!(%{"message" => "Attempt", forbidden => "attacker/value"})
      }

      assert {:ok, refused} =
               Runner.run(snapshot, call, %{context | approval_receipts: [receipt]})

      assert refused["error"]["code"] == "additional_property_not_allowed"
    end
  end

  defp git!(root, args) do
    System.cmd("git", ["-C", root | args], stderr_to_stdout: true) |> successful!()
  end

  defp successful!({output, 0}), do: String.trim(output)

  defp ls_remote!(remote, branch) do
    remote
    |> then(&System.cmd("git", ["ls-remote", &1, "refs/heads/#{branch}"]))
    |> successful!()
    |> String.split()
    |> List.first()
  end

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