test/openagents/tools/connected_repository_tools_test.exs

main at 58e6347eeb72 · 11 KB

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

  import OpenAgents.AccountsFixtures

  alias OpenAgents.Forge.{Repos, WAL}
  alias OpenAgents.Repositories

  alias OpenAgents.Tools.{
    ConnectedRepositoryList,
    ConnectedRepositoryRead,
    ExecutionContext,
    Registry,
    Runner
  }

  setup do
    base =
      Path.join(
        System.tmp_dir!(),
        "connected-repository-tools-#{System.unique_integer([:positive])}"
      )

    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"))

    on_exit(fn ->
      restore_env(:forge_data_dir, previous_data)
      restore_env(:forge_wal_dir, previous_wal)
      File.rm_rf(base)
    end)

    user = repository_user_fixture("connected-repository-reader")
    suffix = System.unique_integer([:positive, :monotonic])
    owner = "RegistryOrg#{suffix}"
    name = "registry-repo-#{suffix}"

    {:ok, repository} =
      Repositories.create_repository(%{
        owner: owner,
        name: name,
        visibility: "public",
        default_branch: "main"
      })

    seed_repository(repository.storage_key)
    {:ok, snapshot} = Registry.build([ConnectedRepositoryRead, ConnectedRepositoryList])

    %{
      context: context(user.id),
      repository: repository,
      repository_path: "#{owner}/#{name}",
      snapshot: snapshot,
      user: user
    }
  end

  test "reads a connected repository file through the registry runner", %{
    context: context,
    repository: repository,
    repository_path: repository_path,
    snapshot: snapshot
  } do
    assert {:ok, outcome} =
             run(snapshot, context, "read_repository_file", %{
               "repository" => repository_path,
               "path" => "README.md",
               "ref" => ""
             })

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["repository"] == repository_path
    assert outcome["result"]["path"] == "README.md"
    assert outcome["result"]["content"] == "# OpenAgents\n\nConnected repository fixture.\n"
    assert outcome["target_receipt_refs"] == ["forge-repository:#{repository.id}"]
  end

  test "uses an empty file path to read the default README", %{
    context: context,
    repository: repository,
    snapshot: snapshot
  } do
    assert {:ok, outcome} =
             run(snapshot, context, "read_repository_file", %{
               "repository" => repository.name,
               "path" => "",
               "ref" => ""
             })

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["path"] == "README.md"
  end

  test "normalizes string null defaults emitted by a provider", %{
    context: context,
    repository_path: repository_path,
    snapshot: snapshot
  } do
    assert {:ok, outcome} =
             run(snapshot, context, "read_repository_file", %{
               "repository" => repository_path,
               "path" => "null",
               "ref" => "null"
             })

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["path"] == "README.md"
    assert outcome["result"]["ref"] == "main"
  end

  test "lists exact repository-relative directory paths", %{
    context: context,
    repository_path: repository_path,
    snapshot: snapshot
  } do
    assert {:ok, root} =
             run(snapshot, context, "list_repository_directory", %{
               "repository" => repository_path,
               "path" => "",
               "ref" => "main"
             })

    assert root["status"] == "succeeded"
    assert Enum.any?(root["result"]["entries"], &match?(%{"path" => "README.md"}, &1))
    assert Enum.any?(root["result"]["entries"], &match?(%{"path" => "docs"}, &1))

    assert {:ok, nested} =
             run(snapshot, context, "list_repository_directory", %{
               "repository" => repository_path,
               "path" => "docs/runbooks",
               "ref" => "main"
             })

    assert nested["result"]["entries"] == [
             %{
               "name" => "production.md",
               "path" => "docs/runbooks/production.md",
               "type" => "file",
               "size_bytes" => 54
             }
           ]
  end

  test "rejects traversal before it reaches Forge.Browse", %{
    context: context,
    repository_path: repository_path,
    snapshot: snapshot
  } do
    assert {:ok, outcome} =
             run(snapshot, context, "read_repository_file", %{
               "repository" => repository_path,
               "path" => "../secrets",
               "ref" => "main"
             })

    assert outcome["status"] == "failed"
    assert outcome["error"]["code"] == "invalid_repository_path"
  end

  test "rejects sensitive credential paths before they reach Forge.Browse", %{
    context: context,
    repository_path: repository_path,
    snapshot: snapshot
  } do
    for path <- [".env", "config/credentials.json", "keys/deploy.pem"] do
      assert {:ok, outcome} =
               run(snapshot, context, "read_repository_file", %{
                 "repository" => repository_path,
                 "path" => path,
                 "ref" => "main"
               })

      assert outcome["status"] == "failed"
      assert outcome["error"]["code"] == "sensitive_repository_path"
    end
  end

  test "does not disclose a private repository without visible access", %{
    context: context,
    snapshot: snapshot
  } do
    {:ok, private_repository} =
      Repositories.create_repository(%{
        owner: "RegistryPrivateOrg",
        name: "private-repo",
        visibility: "private",
        default_branch: "main"
      })

    seed_repository(private_repository.storage_key)

    assert {:ok, outcome} =
             run(snapshot, context, "read_repository_file", %{
               "repository" => "RegistryPrivateOrg/private-repo",
               "path" => "README.md",
               "ref" => "main"
             })

    assert outcome["status"] == "failed"
    assert outcome["error"]["code"] == "repository_not_found"
    assert outcome["error"]["message"] == "The repository does not exist or you cannot access it."
  end

  test "reads a private repository that is visible through membership", %{
    context: context,
    snapshot: snapshot,
    user: user
  } do
    {:ok, private_repository} =
      Repositories.create_repository(%{
        owner: "RegistryMemberOrg",
        name: "member-repo",
        visibility: "private",
        default_branch: "main"
      })

    seed_repository(private_repository.storage_key)
    assert {:ok, _membership} = Repositories.add_member(private_repository, user, "viewer")

    assert {:ok, outcome} =
             run(snapshot, context, "read_repository_file", %{
               "repository" => "RegistryMemberOrg/member-repo",
               "path" => "README.md",
               "ref" => "main"
             })

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["content"] == "# OpenAgents\n\nConnected repository fixture.\n"
  end

  test "requires repository authority and an authenticated conversation owner", %{
    context: context,
    repository_path: repository_path,
    snapshot: snapshot
  } do
    assert {:ok, refused} =
             run(snapshot, %{context | authorities: MapSet.new()}, "read_repository_file", %{
               "repository" => repository_path,
               "path" => "README.md",
               "ref" => "main"
             })

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "authority_refused"

    assert {:ok, unauthenticated} =
             run(snapshot, %{context | owner_user_id: nil}, "read_repository_file", %{
               "repository" => repository_path,
               "path" => "README.md",
               "ref" => "main"
             })

    assert unauthenticated["status"] == "failed"
    assert unauthenticated["error"]["code"] == "repository_authentication_required"
  end

  defp context(user_id) do
    %ExecutionContext{
      scope: "browser_conversation",
      scope_ref: "conversation:connected-repository-test",
      authorities: MapSet.new(["repository.read"]),
      owner_user_id: user_id
    }
  end

  defp run(snapshot, context, name, arguments) do
    Runner.run(
      snapshot,
      %{
        call_id: "call:#{System.unique_integer([:positive])}",
        name: name,
        version: 1,
        raw_arguments: Jason.encode!(arguments)
      },
      context
    )
  end

  defp seed_repository(storage_key) do
    path = Repos.ensure_repo!(storage_key)

    blob =
      git!(
        path,
        ["hash-object", "-w", "--stdin"],
        "# OpenAgents\n\nConnected repository fixture.\n"
      )

    runbook_blob =
      git!(
        path,
        ["hash-object", "-w", "--stdin"],
        "# Production runbook\n\nDeploy only verified revisions.\n"
      )

    runbooks_tree = git!(path, ["mktree"], "100644 blob #{runbook_blob}\tproduction.md\n")
    docs_tree = git!(path, ["mktree"], "040000 tree #{runbooks_tree}\trunbooks\n")

    tree =
      git!(
        path,
        ["mktree"],
        "100644 blob #{blob}\tREADME.md\n040000 tree #{docs_tree}\tdocs\n"
      )

    commit =
      git!(path, ["commit-tree", tree, "-m", "Seed repository"], "",
        env: [
          {"GIT_AUTHOR_NAME", "Test Author"},
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
          {"GIT_COMMITTER_NAME", "Test Author"},
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
        ]
      )

    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", commit])
    persist_wal(storage_key, path, %{"refs/heads/main" => commit})
  end

  defp persist_wal(storage_key, path, refs) do
    bundle_path =
      Path.join(
        System.tmp_dir!(),
        "connected-repository-bundle-#{System.unique_integer([:positive, :monotonic])}.bundle"
      )

    {_, 0} = Repos.git(path, ["bundle", "create", bundle_path, "--all"])
    index_result = WAL.read_index(storage_key)
    index = if match?({:ok, _, _}, index_result), do: elem(index_result, 2), else: WAL.new_index()
    generation = if match?({:ok, _, _}, index_result), do: elem(index_result, 1), else: :none
    sequence = WAL.next_seq(index)
    {:ok, object} = WAL.put_entry_file(storage_key, sequence, bundle_path)

    entry = %{
      "seq" => sequence,
      "object" => object,
      "format" => "git_bundle",
      "refs" => refs,
      "principal" => "connected-repository-tools-test",
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
    }

    try do
      {:ok, _generation} = WAL.cas_index(storage_key, generation, WAL.append_entry(index, entry))
      :ok
    after
      File.rm(bundle_path)
    end
  end

  defp git!(git_dir, args, input, options \\ []) do
    input_path =
      Path.join(
        System.tmp_dir!(),
        "connected-repository-input-#{System.unique_integer([:positive])}"
      )

    File.write!(input_path, input)

    try do
      {output, 0} =
        System.cmd(
          "sh",
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT_PATH"), "sh"] ++ args,
          env:
            [{"GIT_DIR", git_dir}, {"INPUT_PATH", input_path}] ++ Keyword.get(options, :env, [])
        )

      String.trim(output)
    after
      File.rm(input_path)
    end
  end

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