test/openagents_web/controllers/repository_controller_test.exs

58e6347eeb72 · 13 KB

defmodule OpenAgentsWeb.RepositoryControllerTest do
  use OpenAgentsWeb.ConnCase, async: false

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

  test "POST /api/v1/user/repos creates in the authenticated GitHub namespace", %{conn: conn} do
    user = github_user("repository-api-create", "octavia")

    response =
      conn
      |> authorize(user)
      |> put_req_header("idempotency-key", "repo-create-1")
      |> post(~p"/api/v1/user/repos", %{
        name: "My-Project",
        description: "An API-created repository",
        private: true,
        default_branch: "trunk"
      })

    assert %{
             "id" => id,
             "name" => "my-project",
             "full_name" => "octavia/my-project",
             "private" => true,
             "visibility" => "private",
             "default_branch" => "trunk",
             "lifecycle_state" => "provisioning",
             "clone_url" => clone_url,
             "html_url" => html_url,
             "permissions" => %{"admin" => true, "pull" => true, "push" => true}
           } = json_response(response, 202)

    assert String.ends_with?(clone_url, "/octavia/my-project.git")
    assert String.ends_with?(html_url, "/octavia/my-project")
    assert Repositories.get_by_path!("octavia", "my-project").id == id
  end

  test "create requires a bounded idempotency key and rejects conflicting replay", %{conn: conn} do
    user = github_user("repository-api-idempotency")

    assert %{"code" => "invalid_idempotency_key"} =
             conn
             |> authorize(user)
             |> post(~p"/api/v1/user/repos", %{name: "missing-key"})
             |> json_response(400)

    first =
      conn
      |> authorize(user)
      |> put_req_header("idempotency-key", "same-key")
      |> post(~p"/api/v1/user/repos", %{name: "same-request"})

    replayed =
      conn
      |> authorize(user)
      |> put_req_header("idempotency-key", "same-key")
      |> post(~p"/api/v1/user/repos", %{name: "same-request"})

    assert json_response(first, 202)["id"] == json_response(replayed, 202)["id"]

    assert %{"code" => "idempotency_conflict"} =
             conn
             |> authorize(user)
             |> put_req_header("idempotency-key", "same-key")
             |> post(~p"/api/v1/user/repos", %{name: "different-request"})
             |> json_response(409)
  end

  test "GET repository permits public reads and conceals private repositories", %{conn: conn} do
    owner = github_user("repository-api-visibility-owner", "visible-owner")
    viewer = github_user("repository-api-visibility-viewer")

    {:ok, public_repository, :created} =
      Repositories.create_user_repository(
        owner,
        %{name: "public-repo", visibility: "public"},
        "public-create"
      )

    {:ok, private_repository, :created} =
      Repositories.create_user_repository(
        owner,
        %{name: "private-repo", visibility: "private"},
        "private-create"
      )

    mark_ready(public_repository)
    mark_ready(private_repository)

    assert %{"id" => public_id, "permissions" => %{"pull" => true, "push" => false}} =
             conn
             |> get(~p"/api/v1/repos/visible-owner/public-repo")
             |> json_response(200)

    assert public_id == public_repository.id

    assert conn
           |> get(~p"/api/v1/repos/visible-owner/private-repo")
           |> json_response(404)

    assert conn
           |> authorize(viewer)
           |> get(~p"/api/v1/repos/visible-owner/private-repo")
           |> json_response(404)

    {:ok, _membership} = Repositories.add_member(private_repository, viewer, "viewer")

    assert %{"id" => private_id, "permissions" => %{"pull" => true, "push" => false}} =
             conn
             |> authorize(viewer)
             |> get(~p"/api/v1/repos/visible-owner/private-repo")
             |> json_response(200)

    assert private_id == private_repository.id
  end

  test "GET /api/v1/user/repos returns a bounded visible list", %{conn: conn} do
    user = github_user("repository-api-list", "repo-list-owner")

    Enum.each(1..3, fn number ->
      assert {:ok, _repository, :created} =
               Repositories.create_user_repository(
                 user,
                 %{name: "project-#{number}"},
                 "list-key-#{number}"
               )
    end)

    first =
      conn
      |> authorize(user)
      |> get(~p"/api/v1/user/repos?per_page=2")
      |> json_response(200)

    assert length(first["repositories"]) == 2
    assert is_binary(first["next_cursor"])

    second =
      conn
      |> authorize(user)
      |> get(~p"/api/v1/user/repos?per_page=2&after=#{first["next_cursor"]}")
      |> json_response(200)

    assert MapSet.disjoint?(
             MapSet.new(Enum.map(first["repositories"], & &1["id"])),
             MapSet.new(Enum.map(second["repositories"], & &1["id"]))
           )
  end

  test "GET /api/v1/user/repos filters by GitHub namespace", %{conn: conn} do
    user = github_user("repository-api-list-namespace", "repo-list-filter")

    assert {:ok, _repository, :created} =
             Repositories.create_user_repository(
               user,
               %{name: "matching-project"},
               "list-namespace-key"
             )

    response =
      conn
      |> authorize(user)
      |> get(~p"/api/v1/user/repos?namespace=repo-list-filter&per_page=10")
      |> json_response(200)

    assert Enum.map(response["repositories"], & &1["full_name"]) == [
             "repo-list-filter/matching-project"
           ]

    assert %{"repositories" => []} =
             conn
             |> authorize(user)
             |> get(~p"/api/v1/user/repos?namespace=another-owner&per_page=10")
             |> json_response(200)
  end

  test "creation enforces the namespace repository quota", %{conn: conn} do
    previous = Application.get_env(:openagents, :repository_namespace_limit)
    Application.put_env(:openagents, :repository_namespace_limit, 1)

    on_exit(fn ->
      if is_nil(previous),
        do: Application.delete_env(:openagents, :repository_namespace_limit),
        else: Application.put_env(:openagents, :repository_namespace_limit, previous)
    end)

    user = github_user("repository-api-quota", "quota-owner")

    assert conn
           |> authorize(user)
           |> put_req_header("idempotency-key", "quota-first")
           |> post(~p"/api/v1/user/repos", %{name: "first"})
           |> json_response(202)

    assert %{"code" => "repository_quota_exceeded"} =
             conn
             |> authorize(user)
             |> put_req_header("idempotency-key", "quota-second")
             |> post(~p"/api/v1/user/repos", %{name: "second"})
             |> json_response(422)
  end

  test "POST /api/v1/repos creates under a named personal owner", %{conn: conn} do
    user = github_user("repository-api-owner-route-self", "namedowner")

    response =
      conn
      |> authorize(user)
      |> put_req_header("idempotency-key", "owner-route-self")
      |> post(~p"/api/v1/repos", %{owner: "namedowner", name: "Named-Thing", private: true})

    assert %{"full_name" => "namedowner/named-thing", "owner" => owner} =
             json_response(response, 202)

    assert owner["login"] == "namedowner"
    assert Repositories.get_by_path!("namedowner", "named-thing")
  end

  test "POST /api/v1/repos matches a named personal owner without regard to case", %{conn: conn} do
    user = github_user("repository-api-owner-route-case", "MixedCaseOwner")

    assert %{"full_name" => "MixedCaseOwner/cased"} =
             conn
             |> authorize(user)
             |> put_req_header("idempotency-key", "owner-route-case")
             |> post(~p"/api/v1/repos", %{owner: "mixedcaseowner", name: "cased"})
             |> json_response(202)

    assert Repositories.get_by_path!("mixedcaseowner", "cased")
  end

  test "POST /api/v1/repos without an owner uses the caller's own namespace", %{conn: conn} do
    user = github_user("repository-api-owner-route-default", "defaultowner")

    assert %{"full_name" => "defaultowner/implied"} =
             conn
             |> authorize(user)
             |> put_req_header("idempotency-key", "owner-route-default")
             |> post(~p"/api/v1/repos", %{name: "implied"})
             |> json_response(202)
  end

  test "POST /api/v1/repos refuses another person's namespace", %{conn: conn} do
    caller = github_user("repository-api-owner-route-caller", "intruder")
    stranger = github_user("repository-api-owner-route-stranger", "bystander")

    # The stranger's namespace has to exist for the refusal to be about
    # authority rather than about an unknown name.
    assert {:ok, _namespace} = Repositories.ensure_user_namespace(stranger)

    assert %{"code" => "namespace_not_allowed"} =
             conn
             |> authorize(caller)
             |> put_req_header("idempotency-key", "owner-route-stranger")
             |> post(~p"/api/v1/repos", %{owner: "bystander", name: "not-yours"})
             |> json_response(403)

    assert_raise Ecto.NoResultsError, fn ->
      Repositories.get_by_path!("bystander", "not-yours")
    end
  end

  test "POST /api/v1/repos rejects an owner that is not a namespace name", %{conn: conn} do
    user = github_user("repository-api-owner-route-invalid", "shapeowner")

    assert %{"code" => "invalid_repository"} =
             conn
             |> authorize(user)
             |> put_req_header("idempotency-key", "owner-route-invalid")
             |> post(~p"/api/v1/repos", %{owner: "shape owner/nested", name: "wrong"})
             |> json_response(422)
  end

  test "POST /api/v1/repos requires a bearer token" do
    assert build_conn()
           |> put_req_header("idempotency-key", "owner-route-anonymous")
           |> post(~p"/api/v1/repos", %{owner: "anyone", name: "anything"})
           |> response(401)
  end

  test "DELETE /api/v1/repos/:owner/:repo removes an owned repository and its storage", %{
    conn: conn
  } do
    configure_repository_storage("repository-api-delete")
    owner = github_user("repository-api-delete-owner", "delete-owner")

    assert {:ok, repository, :created} =
             Repositories.create_user_repository(
               owner,
               %{name: "delete-me", visibility: "private"},
               "delete-repository"
             )

    mark_ready(repository)
    assert {:ok, _generation} = WAL.cas_index(repository.storage_key, :none, WAL.new_index())
    bare_path = Repos.ensure_repo!(repository.storage_key)

    response =
      conn
      |> authorize(owner)
      |> delete("/api/v1/repos/delete-owner/delete-me")

    assert response.status == 204
    assert response.resp_body == ""

    assert_raise Ecto.NoResultsError, fn ->
      Repositories.get_by_path!("delete-owner", "delete-me")
    end

    assert {:error, :not_found} = WAL.read_index(repository.storage_key)
    refute File.exists?(bare_path)
  end

  test "DELETE /api/v1/repos/:owner/:repo permits only repository owners", %{conn: conn} do
    owner = github_user("repository-api-delete-authorization-owner", "protected-owner")
    maintainer = github_user("repository-api-delete-authorization-maintainer")

    assert {:ok, repository, :created} =
             Repositories.create_user_repository(
               owner,
               %{name: "protected-repository", visibility: "private"},
               "protected-repository"
             )

    mark_ready(repository)
    assert {:ok, _membership} = Repositories.add_member(repository, maintainer, "maintainer")

    assert %{"code" => "not_found"} =
             conn
             |> authorize(maintainer)
             |> delete("/api/v1/repos/protected-owner/protected-repository")
             |> json_response(404)

    assert Repositories.get_by_path!("protected-owner", "protected-repository").id ==
             repository.id
  end

  defp authorize(conn, user) do
    {:ok, _credential, plaintext} =
      ApiTokens.create(user, %{name: "repository API test", scopes: ["forge:write"]})

    put_req_header(conn, "authorization", "Bearer " <> plaintext)
  end

  defp mark_ready(repository) do
    repository
    |> Ecto.Changeset.change(
      lifecycle_state: "ready",
      ready_at: DateTime.utc_now()
    )
    |> OpenAgents.Repo.update!()
  end

  defp configure_repository_storage(key) do
    root = Path.join(System.tmp_dir!(), "#{key}-#{System.unique_integer([:positive])}")
    previous_data = Application.get_env(:openagents, :forge_data_dir)
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
    previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)

    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "data"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)

    on_exit(fn ->
      restore_env(:forge_data_dir, previous_data)
      restore_env(:forge_wal_dir, previous_wal)
      restore_env(:forge_wal_adapter, previous_adapter)
      File.rm_rf!(root)
    end)

    root
  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