5.7 KiB · text History b868907
defmodule GitGudWeb.PrivateRepoAccessTest do
  @moduledoc """
  A private repository must not be readable by someone who can't read
  it — through any page, not just the ones anybody remembered.

  This exists because it wasn't true. The router said "visibility is
  enforced inside the LiveView itself" and eighteen of the nineteen
  browsing LiveViews fetched the repository and rendered it, so a
  private repository's file tree, blobs, commits, issues, pull
  requests, wiki and CI were readable by anyone who typed the URL. The
  listings hid them correctly, which is what made it survive.

  So the test is deliberately written against the *route table* rather
  than against a hand-picked list of pages: every repository-scoped
  path is asserted, and a new page that forgets the gate fails here.
  """
  use GitGudWeb.ConnCase, async: false

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  alias GitGud.Repositories

  setup do
    owner = user_fixture()

    {:ok, private} =
      Repositories.create_repository(owner, %{
        "name" => unique_repo_name(),
        "visibility" => "private"
      })

    {:ok, public} =
      Repositories.create_repository(owner, %{
        "name" => unique_repo_name(),
        "visibility" => "public"
      })

    {:ok, internal} =
      Repositories.create_repository(owner, %{
        "name" => unique_repo_name(),
        "visibility" => "internal"
      })

    %{owner: owner, private: private, public: public, internal: internal}
  end

  # Every read-only page a repository has. Paths, not LiveView modules,
  # because what leaked was reachable URLs.
  defp browsing_paths(handle, name) do
    [
      "/r/#{handle}/#{name}",
      "/r/#{handle}/#{name}/tree/master/",
      "/r/#{handle}/#{name}/blob/master/README.md",
      "/r/#{handle}/#{name}/commits/master",
      "/r/#{handle}/#{name}/issues",
      "/r/#{handle}/#{name}/pulls",
      "/r/#{handle}/#{name}/wiki",
      "/r/#{handle}/#{name}/actions",
      "/r/#{handle}/#{name}/forks",
      "/r/#{handle}/#{name}/compare",
      "/r/#{handle}/#{name}/packages",
      "/r/#{handle}/#{name}/stats/languages",
      "/r/#{handle}/#{name}/stats/committers"
    ]
  end

  describe "an anonymous visitor" do
    test "gets a 404 from every page of a private repository", %{
      conn: conn,
      owner: owner,
      private: private
    } do
      for path <- browsing_paths(owner.handle, private.name) do
        assert_404(conn, path)
      end
    end

    test "can still read a public repository", %{conn: conn, owner: owner, public: public} do
      # The gate has to be a gate, not a wall — this is the assertion
      # that would catch closing it too far.
      assert conn |> get("/r/#{owner.handle}/#{public.name}") |> html_response(200)
    end
  end

  describe "a signed-in stranger" do
    test "gets a 404 from every page of a private repository", %{
      conn: conn,
      owner: owner,
      private: private
    } do
      conn = log_in_user(conn, user_fixture())

      # Being signed in says nothing about this repository, and a 403
      # here would confirm it exists.
      for path <- browsing_paths(owner.handle, private.name) do
        assert_404(conn, path)
      end
    end
  end

  describe "the owner" do
    test "can read their own private repository", %{conn: conn, owner: owner, private: private} do
      assert conn
             |> log_in_user(owner)
             |> get("/r/#{owner.handle}/#{private.name}")
             |> html_response(200)
    end
  end

  describe "indistinguishability" do
    test "a private repository answers exactly like one that isn't there", %{
      conn: conn,
      owner: owner,
      private: private
    } do
      missing = catch_status(conn, "/r/#{owner.handle}/definitely-not-a-repo")
      hidden = catch_status(conn, "/r/#{owner.handle}/#{private.name}")

      # Same status, so probing the URL space tells an attacker nothing
      # about which private repositories exist.
      assert missing == hidden
      assert hidden == 404
    end
  end

  describe "internal repositories" do
    test "are not readable by an anonymous visitor", %{
      conn: conn,
      owner: owner,
      internal: internal
    } do
      # The settings page says "Internal — any signed-in user can read".
      # Nobody is not a signed-in user. This used to be readable, while
      # every listing correctly hid it, so an internal repository was
      # invisible and world-readable at the same time.
      for path <- browsing_paths(owner.handle, internal.name) do
        assert_404(conn, path)
      end
    end

    test "are readable by any signed-in user", %{conn: conn, owner: owner, internal: internal} do
      # The other half of the promise: internal is not private, and
      # closing it to everyone would be a different bug.
      assert conn
             |> log_in_user(user_fixture())
             |> get("/r/#{owner.handle}/#{internal.name}")
             |> html_response(200)
    end

    test "are not listed to an anonymous visitor on a profile", %{
      conn: conn,
      owner: owner,
      internal: internal,
      public: public
    } do
      html = conn |> get("/u/#{owner.handle}") |> html_response(200)

      assert html =~ public.name
      refute html =~ internal.name
    end
  end

  defp assert_404(conn, path) do
    assert catch_status(conn, path) == 404, "#{path} did not 404"
  end

  # A LiveView that refuses to mount raises; Phoenix maps
  # Ecto.NoResultsError to a 404. Either shape counts as "not served".
  defp catch_status(conn, path) do
    try do
      get(conn, path).status
    rescue
      Ecto.NoResultsError -> 404
      Phoenix.Router.NoRouteError -> 404
    catch
      :exit, {{%Ecto.NoResultsError{}, _}, _} -> 404
    end
  end
end