test/openagents/issues_workspace_test.exs

58e6347eeb72 · 5 KB

defmodule OpenAgents.IssuesWorkspaceTest do
  @moduledoc """
  What a workspace-wide issue read may return, and how much of it.

  The repository-scoped list is handed a repository the caller has already
  authorized. This one authorizes as it reads, so the interesting questions
  are the ones a per-repository list never has to ask: whether a private
  repository can leak into someone else's result, whether a filter can widen
  the set rather than narrow it, and whether the query stays bounded.
  """
  use OpenAgents.DataCase

  import OpenAgents.AccountsFixtures

  alias OpenAgents.Issues
  alias OpenAgents.Repo
  alias OpenAgents.Repositories

  setup do
    owner = repository_user_fixture("workspace-owner")
    member = repository_user_fixture("workspace-member")
    outsider = repository_user_fixture("workspace-outsider")

    private = ready_repository!(owner, "workspace-private", "private")
    {:ok, _membership} = Repositories.add_member(private, member, "viewer")

    {:ok, secret} =
      Issues.create_issue(private, %{"title" => "Rotate the signing key"}, owner)

    %{owner: owner, member: member, outsider: outsider, private: private, secret: secret}
  end

  test "a private repository's issue reaches a member", context do
    assert context.secret.id in visible_ids(context.member)
    assert context.secret.id in visible_ids(context.owner)
  end

  test "a private repository's issue does not reach a stranger", context do
    refute context.secret.id in visible_ids(context.outsider)
    assert Issues.count_visible_issues(context.outsider, state: "open") >= 0
  end

  test "a stranger cannot reach it by filtering for it either", context do
    for opts <- [
          [q: "Rotate the signing key"],
          [assignee: context.owner.github_login],
          [author: context.owner],
          [state: "all"]
        ] do
      {issues, _total} = Issues.list_visible_issues_page(context.outsider, opts)

      refute context.secret.id in Enum.map(issues, & &1.id),
             "leaked through #{inspect(opts)}"
    end
  end

  test "losing membership loses the issue", context do
    assert context.secret.id in visible_ids(context.member)

    Repo.delete_all(
      from membership in Repositories.Membership,
        where:
          membership.repository_id == ^context.private.id and
            membership.user_id == ^context.member.id
    )

    refute context.secret.id in visible_ids(context.member)
  end

  test "a public repository's issue reaches everyone", context do
    public = ready_repository!(context.owner, "workspace-public", "public")
    {:ok, issue} = Issues.create_issue(public, %{"title" => "Public business"}, context.owner)

    assert issue.id in visible_ids(context.outsider)
  end

  test "assignee and author narrow rather than widen", context do
    {:ok, mine} =
      Issues.create_issue(
        context.private,
        %{"title" => "Mine", "assignees" => [context.owner.github_login]},
        context.member
      )

    assigned = visible_ids(context.member, assignee: context.owner.github_login)
    authored = visible_ids(context.member, author: context.member)

    assert mine.id in assigned
    refute context.secret.id in assigned

    assert mine.id in authored
    refute context.secret.id in authored
  end

  test "an imported issue counts as opened by the login that opened it", context do
    {:ok, imported} =
      Issues.create_issue(context.private, %{
        "title" => "Filed on GitHub",
        "user" => %{"login" => context.member.github_login}
      })

    assert imported.author_user_id == nil
    assert imported.id in visible_ids(context.member, author: context.member)
  end

  test "the closed state is a filter, not a second query", context do
    {:ok, _closed} =
      Issues.update_issue(context.secret, %{"state" => "closed"}, context.owner)

    refute context.secret.id in visible_ids(context.member, state: "open")
    assert context.secret.id in visible_ids(context.member, state: "closed")
  end

  test "one page is bounded and the page number is clamped", context do
    for index <- 1..(Issues.per_page() + 5) do
      {:ok, _issue} =
        Issues.create_issue(context.private, %{"title" => "Bulk #{index}"}, context.owner)
    end

    {first, total} = Issues.list_visible_issues_page(context.member, state: "open")

    assert length(first) == Issues.per_page()
    assert total > Issues.per_page()

    {second, ^total} = Issues.list_visible_issues_page(context.member, state: "open", page: 2)

    assert second != []
    assert Enum.all?(second, &(&1.id not in Enum.map(first, fn issue -> issue.id end)))

    # A hand-typed page number cannot walk past the ceiling, and a page beyond
    # the results is empty rather than an error.
    {beyond, ^total} =
      Issues.list_visible_issues_page(context.member, state: "open", page: "999999999")

    assert beyond == []
  end

  test "rows arrive with their repository, because the list has to name it", context do
    {[issue | _rest], _total} = Issues.list_visible_issues_page(context.member, state: "open")

    assert issue.repository.owner == context.private.owner
    assert issue.repository.name == context.private.name
  end

  defp visible_ids(user, opts \\ []) do
    {issues, _total} = Issues.list_visible_issues_page(user, Keyword.put_new(opts, :state, "all"))
    Enum.map(issues, & &1.id)
  end

  defp ready_repository!(owner, name, visibility) do
    {:ok, repository, :created} =
      Repositories.create_user_repository(
        owner,
        %{name: name, visibility: visibility},
        "#{name}-key"
      )

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