test/openagents/repositories/cli_local_e2e_test.exs

main at 58e6347eeb72 · 13 KB

defmodule OpenAgents.Repositories.CliLocalE2ETest do
  use OpenAgents.DataCase, async: false

  import OpenAgents.AccountsFixtures

  alias OpenAgents.{Accounts, ApiTokens, Repositories}
  alias OpenAgents.Forge.{Repos, WAL}
  alias OpenAgents.Repositories.{Importer, Provisioner}

  @moduletag :cross_repo

  setup do
    cli_entry = System.fetch_env!("OPENAGENTS_CLI_ENTRY")

    node =
      System.find_executable("node") || raise "node is required for the CLI cross-repository test"

    true = Path.type(cli_entry) == :absolute
    true = File.regular?(cli_entry)

    root =
      Path.join(
        System.tmp_dir!(),
        "openagents-cli-local-e2e-#{Ecto.UUID.generate()}"
      )

    File.mkdir_p!(root)
    previous = save_environment([:forge_data_dir, :forge_wal_dir, :github_api])
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "forge"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))

    user = repository_user_fixture("cli-e2e-owner")
    {:ok, user} = Accounts.store_github_token(user, "github-cli-e2e-token")

    {:ok, _api_token, plaintext} =
      ApiTokens.create(user, %{name: "CLI local E2E", scopes: ["forge:write"], lifetime_days: 1})

    source = fixture_repository!(root)
    refs = refs(source)

    github_port = free_port()
    endpoint_port = System.get_env("OPENAGENTS_E2E_PORT", "4000") |> String.to_integer()
    api_origin = "http://localhost:#{endpoint_port}"

    github_options = [
      source_full_name: "#{user.github_login}/source-project",
      repository_id: 9_001,
      owner_id: user.github_id,
      default_branch: "main",
      refs:
        Map.new(refs, fn {name, sha} ->
          {type, 0} = System.cmd("git", ["-C", source, "cat-file", "-t", sha])
          {name, %{sha: sha, type: String.trim(type)}}
        end)
    ]

    start_supervised!(
      Supervisor.child_spec(
        {Bandit,
         plug: {OpenAgents.Test.RepositoryCliGitHubFake, github_options},
         port: github_port,
         ip: {127, 0, 0, 1}},
        id: :repository_cli_github_fake
      )
    )

    Application.put_env(:openagents, :github_api,
      base_url: "http://127.0.0.1:#{github_port}",
      request_options: []
    )

    start_supervised!(
      Supervisor.child_spec(
        {Bandit, plug: OpenAgentsWeb.Endpoint, port: endpoint_port, ip: {127, 0, 0, 1}},
        id: :repository_cli_local_endpoint
      )
    )

    bin = Path.join(root, "bin")
    File.mkdir_p!(bin)
    wrapper = Path.join(bin, "openagents")

    File.write!(
      wrapper,
      "#!/bin/sh\nexec node \"$OPENAGENTS_CLI_ENTRY\" \"$@\"\n",
      [:exclusive]
    )

    File.chmod!(wrapper, 0o700)

    on_exit(fn ->
      restore_environment(previous)
      File.rm_rf!(root)
    end)

    %{
      cli_entry: cli_entry,
      node: node,
      environment: [
        {"OPENAGENTS_API_URL", api_origin},
        {"OPENAGENTS_TOKEN", plaintext},
        {"OPENAGENTS_CLI_ENTRY", cli_entry},
        {"PATH", bin <> ":" <> System.fetch_env!("PATH")},
        {"NO_COLOR", "1"}
      ],
      api_origin: api_origin,
      root: root,
      secret: plaintext,
      source: source,
      source_refs: refs,
      user: user
    }
  end

  test "CLI creates, pushes, clones, reconstructs, and imports once", context do
    checkout = local_checkout!(context.root, "created-source")

    create_task =
      cli_task(context, checkout, [
        "--json",
        "repo",
        "create",
        "created-project",
        "--public",
        "--source",
        checkout,
        "--wait-timeout",
        "20"
      ])

    assert :processed = await_outbox(&Provisioner.run_once/0)
    create_output = await_cli!(create_task, context.secret)
    assert create_output =~ "\"full_name\":\"#{context.user.github_login}/created-project\""

    setup_git_output =
      cli!(context, checkout, ["--json", "auth", "setup-git", "--local"])

    refute setup_git_output =~ context.secret
    git!(checkout, ["push", "-u", "origin", "HEAD:main"], context.environment)
    expected_sha = git!(checkout, ["rev-parse", "HEAD"], context.environment) |> String.trim()

    clone = Path.join(context.root, "cli-clone")

    clone_output =
      cli!(context, context.root, [
        "--json",
        "repo",
        "clone",
        "#{context.user.github_login}/created-project",
        clone
      ])

    refute clone_output =~ context.secret

    assert git!(clone, ["rev-parse", "HEAD"], context.environment) |> String.trim() ==
             expected_sha

    repository = Repositories.get_by_path!(context.user.github_login, "created-project")
    File.rm_rf!(Repos.bare_path(repository.storage_key))

    reconstructed = Path.join(context.root, "reconstructed-clone")

    _output =
      cli!(context, context.root, [
        "--json",
        "repo",
        "clone",
        "#{context.user.github_login}/created-project",
        reconstructed
      ])

    assert git!(reconstructed, ["rev-parse", "HEAD"], context.environment) |> String.trim() ==
             expected_sha

    anonymous = Path.join(context.root, "anonymous-clone")

    git!(
      context.root,
      [
        "-c",
        "credential.helper=",
        "clone",
        "#{context.api_origin}/#{context.user.github_login}/created-project.git",
        anonymous
      ],
      [{"GIT_TERMINAL_PROMPT", "0"}]
    )

    assert git!(anonymous, ["rev-parse", "HEAD"], []) |> String.trim() == expected_sha

    private_task =
      cli_task(context, context.root, [
        "--json",
        "repo",
        "create",
        "private-project",
        "--wait-timeout",
        "20"
      ])

    assert :processed = await_outbox(&Provisioner.run_once/0)
    _private_output = await_cli!(private_task, context.secret)
    private_repository = Repositories.get_by_path!(context.user.github_login, "private-project")

    private_url =
      "#{context.api_origin}/#{context.user.github_login}/private-project.git"

    {anonymous_private_output, anonymous_private_status} =
      git_raw(
        context.root,
        ["-c", "credential.helper=", "clone", private_url, Path.join(context.root, "concealed")],
        [{"GIT_TERMINAL_PROMPT", "0"}]
      )

    assert anonymous_private_status != 0
    refute anonymous_private_output =~ context.secret

    viewer = repository_user_fixture("cli-e2e-viewer")
    {:ok, _membership} = Repositories.add_member(private_repository, viewer, "viewer")

    {:ok, _viewer_token, viewer_plaintext} =
      ApiTokens.create(viewer, %{
        name: "CLI E2E viewer",
        scopes: ["forge:write"],
        lifetime_days: 1
      })

    viewer_context = %{
      context
      | secret: viewer_plaintext,
        environment:
          replace_environment(context.environment, "OPENAGENTS_TOKEN", viewer_plaintext)
    }

    viewer_clone = Path.join(context.root, "viewer-clone")

    _viewer_output =
      cli!(viewer_context, context.root, [
        "--json",
        "repo",
        "clone",
        "#{context.user.github_login}/private-project",
        viewer_clone
      ])

    cli!(viewer_context, viewer_clone, ["--json", "auth", "setup-git", "--local"])
    git!(viewer_clone, ["config", "user.email", "viewer@example.com"], [])
    git!(viewer_clone, ["config", "user.name", "Read-only viewer"], [])
    File.write!(Path.join(viewer_clone, "refused.txt"), "must not land\n")
    git!(viewer_clone, ["add", "refused.txt"], [])
    git!(viewer_clone, ["commit", "-m", "Refused viewer push"], [])

    {viewer_push_output, viewer_push_status} =
      git_raw(viewer_clone, ["push", "origin", "HEAD:main"], viewer_context.environment)

    assert viewer_push_status != 0
    refute viewer_push_output =~ viewer_plaintext
    assert Repos.refs(private_repository.storage_key) == %{}

    import_task =
      cli_task(context, context.root, [
        "--json",
        "repo",
        "import",
        "#{context.user.github_login}/source-project",
        "--name",
        "imported-project",
        "--wait-timeout",
        "20"
      ])

    executor = fn work -> Importer.import(work.repository, source_url: context.source) end
    assert :processed = await_outbox(fn -> Provisioner.run_once(executor) end)
    import_output = await_cli!(import_task, context.secret)

    assert import_output =~ "This is a one-time import" or
             import_output =~ "\"state\":\"completed\""

    imported = Repositories.get_by_path!(context.user.github_login, "imported-project")
    assert {:ok, _generation, imported_index} = WAL.read_index(imported.storage_key)
    assert WAL.refs(imported_index) == context.source_refs

    File.write!(Path.join(context.source, "later.txt"), "later GitHub change\n")
    git!(context.source, ["add", "later.txt"], [])
    git!(context.source, ["commit", "-m", "Later source change"], [])
    assert WAL.refs(imported_index) == context.source_refs
    assert Repos.refs(imported.storage_key) == context.source_refs

    remote_config = git!(checkout, ["config", "--get", "remote.origin.url"], [])
    refute remote_config =~ context.secret
    refute remote_config =~ "@localhost"

    write_receipt(context, expected_sha, imported)
  end

  defp cli_task(context, directory, arguments) do
    Task.async(fn -> cli_raw(context, directory, arguments) end)
  end

  defp cli!(context, directory, arguments) do
    context
    |> cli_raw(directory, arguments)
    |> assert_cli_success!(context.secret)
  end

  defp cli_raw(context, directory, arguments) do
    System.cmd(context.node, [context.cli_entry | arguments],
      cd: directory,
      env: context.environment,
      stderr_to_stdout: true
    )
  end

  defp await_cli!(task, secret) do
    task
    |> Task.await(30_000)
    |> assert_cli_success!(secret)
  end

  defp assert_cli_success!({output, 0}, secret) do
    refute output =~ secret
    output
  end

  defp assert_cli_success!({output, status}, _secret) do
    flunk("CLI exited with #{status}:\n#{output}")
  end

  defp await_outbox(run, remaining \\ 500)
  defp await_outbox(_run, 0), do: flunk("repository outbox work was not accepted")

  defp await_outbox(run, remaining) do
    case run.() do
      :processed ->
        :processed

      :idle ->
        receive do
        after
          10 -> await_outbox(run, remaining - 1)
        end
    end
  end

  defp local_checkout!(root, name) do
    path = Path.join(root, name)
    File.mkdir_p!(path)
    git!(path, ["init", "--initial-branch=main"], [])
    git!(path, ["config", "user.email", "cli-e2e@example.com"], [])
    git!(path, ["config", "user.name", "CLI E2E"], [])
    File.write!(Path.join(path, "README.md"), "CLI local E2E\n")
    git!(path, ["add", "README.md"], [])
    git!(path, ["commit", "-m", "Initial commit"], [])
    path
  end

  defp fixture_repository!(root) do
    path = local_checkout!(root, "github-source")
    git!(path, ["branch", "release"], [])
    git!(path, ["tag", "v1"], [])
    path
  end

  defp refs(path) do
    path
    |> git!(["for-each-ref", "--format=%(objectname) %(refname)"], [])
    |> String.split("\n", trim: true)
    |> Map.new(fn line ->
      [sha, name] = String.split(line, " ", parts: 2)
      {name, sha}
    end)
  end

  defp git!(directory, arguments, environment) do
    case git_raw(directory, arguments, environment) do
      {output, 0} -> output
      {output, status} -> flunk("git exited with #{status}:\n#{output}")
    end
  end

  defp git_raw(directory, arguments, environment) do
    System.cmd("git", arguments,
      cd: directory,
      env: environment,
      stderr_to_stdout: true
    )
  end

  defp replace_environment(environment, key, value) do
    Enum.map(environment, fn
      {^key, _old_value} -> {key, value}
      pair -> pair
    end)
  end

  defp write_receipt(context, pushed_sha, imported) do
    case System.get_env("OPENAGENTS_E2E_RECEIPT_PATH") do
      nil ->
        :ok

      path ->
        repository_import =
          imported
          |> OpenAgents.Repo.preload(:repository_import)
          |> Map.fetch!(:repository_import)

        contract_path =
          Application.app_dir(:openagents, "priv/api-contracts/repositories-v1.json")

        contract_digest =
          contract_path
          |> File.read!()
          |> then(&:crypto.hash(:sha256, &1))
          |> Base.encode16(case: :lower)

        body = %{
          "api_origin" => context.api_origin,
          "cli_revision" => System.get_env("OPENAGENTS_CLI_REVISION", "working-tree"),
          "contract_sha256" => contract_digest,
          "created_commit_sha" => pushed_sha,
          "import_ref_digest" => repository_import.source_ref_digest,
          "server_revision" => System.get_env("OPENAGENTS_SERVER_REVISION", "working-tree"),
          "secret_scan" => "passed",
          "test_count" => 1
        }

        File.write!(path, Jason.encode_to_iodata!(body, pretty: true))
    end
  end

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

  defp save_environment(keys), do: Map.new(keys, &{&1, Application.get_env(:openagents, &1)})

  defp restore_environment(environment) do
    Enum.each(environment, fn
      {key, nil} -> Application.delete_env(:openagents, key)
      {key, value} -> Application.put_env(:openagents, key, value)
    end)
  end
end