10.2 KiB · text History b868907
defmodule GitGudWeb.RepoAuditTest do
  @moduledoc """
  A repository's life leaves audit entries — created (by hand, by fork,
  over the API), its settings and visibility, its interaction policy,
  deleted — and its own audit page shows them to the people who may
  see them.
  """
  use GitGudWeb.ConnCase, async: false

  import Ecto.Query
  import Phoenix.LiveViewTest
  import GitGud.AccountsFixtures

  alias GitGud.Accounts
  alias GitGud.Audit.Entry
  alias GitGud.Organizations
  alias GitGud.Repositories

  @moduletag :git_required

  defp entries, do: GitGud.Repo.all(from e in Entry, order_by: e.id)
  defp entries(repo), do: Enum.filter(entries(), &(&1.repository_id == repo.id))

  defp org_fixture(admin) do
    handle = "ra#{System.unique_integer([:positive])}"
    {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle, "name" => handle})
    org
  end

  defp repo_for(user, name \\ "proj", visibility \\ "public") do
    {:ok, repo} =
      Repositories.create_repository(user, %{"name" => name, "visibility" => visibility})

    repo
  end

  defp rpath(repo, suffix \\ "") do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])
    "/r/#{GitGud.Repositories.Storage.repo_handle(repo)}/#{repo.name}" <> suffix
  end

  describe "creating" do
    test "a personal repository", %{conn: conn} do
      user = user_fixture()
      {:ok, lv, _} = live(log_in_user(conn, user), ~p"/repositories/new")

      lv
      |> form("#new-repo-form", %{"repository" => %{"name" => "fresh", "visibility" => "private"}})
      |> render_submit()

      assert [entry] = entries()
      assert entry.action == "repo.created"
      assert entry.actor_id == user.id
      assert entry.repository_label == "#{user.handle}/fresh"
      assert entry.metadata == %{"visibility" => "private"}
      assert entry.organization_id == nil
    end

    test "an org's repository belongs to the org's log", %{conn: conn} do
      admin = user_fixture()
      org = org_fixture(admin)
      {:ok, lv, _} = live(log_in_user(conn, admin), ~p"/repositories/new/#{org.handle}")

      lv
      |> form("#new-repo-form", %{"repository" => %{"name" => "web", "visibility" => "private"}})
      |> render_submit(%{"namespace" => "org:#{org.handle}"})

      assert [%Entry{action: "repo.created", organization_id: oid}] = entries()
      assert oid == org.id
    end

    test "a failed create records nothing", %{conn: conn} do
      user = user_fixture()
      _ = repo_for(user, "taken")
      {:ok, lv, _} = live(log_in_user(conn, user), ~p"/repositories/new")

      lv
      |> form("#new-repo-form", %{"repository" => %{"name" => "taken"}})
      |> render_submit()

      assert entries() == []
    end

    test "a fork, on both repositories", %{conn: conn} do
      owner = user_fixture()
      source = repo_for(owner, "upstreamy")
      forker = user_fixture()

      {:ok, lv, _} = live(log_in_user(conn, forker), rpath(source, "/fork"))
      render_submit(lv, "save", %{"namespace" => "user", "name" => "mine"})

      fork = Repositories.get_repository_by_path!(forker.handle, "mine")
      assert [created] = entries(fork)
      assert created.metadata == %{"forked_from" => "#{owner.handle}/upstreamy"}

      assert [forked] = entries(source)
      assert forked.action == "repo.forked"
      assert forked.metadata == %{"destination" => "#{forker.handle}/mine"}
    end

    test "over the API, with the address it came from" do
      admin = user_fixture()
      org = org_fixture(admin)
      {:ok, _t, token} = Accounts.create_personal_access_token(admin, "t", ["repo"])

      conn =
        build_conn()
        |> put_req_header("authorization", "token " <> token)
        |> put_req_header("user-agent", "gg-test/1")
        |> Map.put(:remote_ip, {198, 51, 100, 4})

      conn = post(conn, "/api/v1/orgs/#{org.handle}/repos", %{"name" => "api-made"})
      assert conn.status == 201

      assert [entry] = entries()
      assert {entry.action, entry.via} == {"repo.created", "api"}
      assert {entry.ip, entry.user_agent} == {"198.51.100.4", "gg-test/1"}
    end
  end

  describe "settings" do
    setup %{conn: conn} do
      user = user_fixture()
      repo = repo_for(user)
      {:ok, lv, _} = live(log_in_user(conn, user), rpath(repo, "/settings"))
      {:ok, user: user, repo: repo, lv: lv}
    end

    test "one save, an entry per kind of change", %{repo: repo, lv: lv} do
      save = fn attrs -> render_submit(lv, "save", %{"repository" => attrs}) end

      save.(%{"description" => "now with words", "visibility" => "private"})
      save.(%{"description" => "now with words", "visibility" => "private"})

      assert [settings, visibility] = entries(repo)
      assert settings.action == "repo.settings_updated"
      assert settings.metadata == %{"description" => "changed"}
      assert visibility.action == "repo.visibility_changed"
      assert visibility.metadata == %{"from" => "public", "to" => "private"}
    end

    test "a changed setting keeps old and new", %{repo: repo, lv: lv} do
      render_submit(lv, "save", %{"repository" => %{"required_approvals" => "2"}})

      assert [entry] = entries(repo)
      assert entry.metadata == %{"required_approvals" => "0 → 2"}
    end

    test "the interaction policy", %{conn: conn, user: user, repo: repo} do
      {:ok, lv, _} = live(log_in_user(conn, user), rpath(repo, "/settings/interaction"))
      before = repo.interaction_policy
      other = Enum.find(~w(public followers approved), &(&1 != before))

      render_submit(lv, "save", %{"repository" => %{"interaction_policy" => other}})
      render_submit(lv, "save", %{"repository" => %{"interaction_policy" => other}})

      assert [entry] = entries(repo)
      assert entry.action == "repo.interaction_policy_changed"
      assert entry.metadata == %{"from" => before, "to" => other}
    end

    test "deleting keeps the name after the repository is gone", %{user: user, repo: repo, lv: lv} do
      render_change(lv, "delete_confirm", %{"value" => repo.name})
      render_click(lv, "delete", %{})

      assert [entry] = entries()
      assert entry.action == "repo.deleted"
      assert entry.repository_id == nil
      assert entry.repository_label == "#{user.handle}/#{repo.name}"
    end
  end

  test "deleting over the API" do
    admin = user_fixture()
    org = org_fixture(admin)
    {:ok, repo} = Repositories.create_repository_for_org(org, admin, %{"name" => "doomed"})
    {:ok, _t, token} = Accounts.create_personal_access_token(admin, "t", ["repo"])

    conn =
      build_conn()
      |> put_req_header("authorization", "token " <> token)
      |> delete("/api/v1/orgs/#{org.handle}/repos/doomed")

    assert conn.status == 204
    assert [entry] = entries()
    assert {entry.action, entry.organization_id, entry.via} == {"repo.deleted", org.id, "api"}
    refute GitGud.Repo.get(Repositories.Repository, repo.id)
  end

  describe "the repository's audit page" do
    test "its owner sees its entries, not others'", %{conn: conn} do
      user = user_fixture()
      repo = repo_for(user)
      other = repo_for(user, "other")
      conn = log_in_user(conn, user)

      {:ok, lv, _} = live(conn, rpath(repo, "/settings"))
      render_submit(lv, "save", %{"repository" => %{"description" => "x"}})
      {:ok, lv, _} = live(conn, rpath(other, "/settings"))
      render_submit(lv, "save", %{"repository" => %{"description" => "y"}})

      [mine] = entries(repo)
      [theirs] = entries(other)
      {:ok, view, _} = live(conn, rpath(repo, "/settings/audit"))

      assert has_element?(view, "#audit-entry-#{mine.id}")
      refute has_element?(view, "#audit-entry-#{theirs.id}")
      assert has_element?(view, "#audit-category-settings")
    end

    test "an org repository's: org admins and instance admins, not team admins", %{conn: conn} do
      admin = user_fixture()
      org = org_fixture(admin)
      # Public: an instance admin isn't a reader of a private repository,
      # and RepoAuth turns them away before the page is reached.
      {:ok, repo} =
        Repositories.create_repository_for_org(org, admin, %{
          "name" => "svc",
          "visibility" => "public"
        })

      {:ok, team} = Organizations.create_team(org, %{"name" => "ops"})
      team_admin = user_fixture()
      {:ok, _} = Organizations.add_member(org, team_admin)
      {:ok, _} = Organizations.add_team_member(team, team_admin, "maintainer")
      {:ok, _} = Organizations.grant_team_repo(team, repo, "admin")

      staff = user_fixture() |> Accounts.User.admin_changeset(true) |> GitGud.Repo.update!()

      for user <- [admin, staff] do
        assert {:ok, _, _} = live(log_in_user(conn, user), rpath(repo, "/settings/audit"))
      end

      for user <- [team_admin, user_fixture()] do
        assert {:error, {:redirect, %{to: to}}} =
                 live(log_in_user(conn, user), rpath(repo, "/settings/audit"))

        assert to == rpath(repo)
      end
    end

    test "filters by who, not by repository", %{conn: conn} do
      user = user_fixture()
      repo = repo_for(user)
      conn = log_in_user(conn, user)

      {:ok, lv, _} = live(conn, rpath(repo, "/settings"))
      render_submit(lv, "save", %{"repository" => %{"description" => "x"}})
      [mine] = entries(repo)

      base = rpath(repo, "/settings/audit")
      {:ok, view, _} = live(conn, base <> "/all/actor/#{user.handle}")
      assert has_element?(view, "#audit-entry-#{mine.id}")
      refute has_element?(view, "#audit-filter select")

      {:ok, view, _} = live(conn, base <> "/all/actor/someone-else")
      refute has_element?(view, "#audit-entry-#{mine.id}")

      assert {:error, {:live_redirect, %{to: ^base}}} = live(conn, base <> "/all/repo/x")
    end

    test "it takes sudo mode", %{conn: conn} do
      user = user_fixture()
      repo = repo_for(user)
      stale = DateTime.add(DateTime.utc_now(:second), -30, :minute)
      conn = log_in_user(conn, user, token_authenticated_at: stale)

      assert {:error, {:redirect, %{to: to}}} = live(conn, rpath(repo, "/settings/audit"))
      assert to =~ "/users/log-in?"
    end

    test "the settings header links to it", %{conn: conn} do
      user = user_fixture()
      repo = repo_for(user)
      {:ok, view, _} = live(log_in_user(conn, user), rpath(repo, "/settings"))

      assert has_element?(view, ~s(a[href="#{rpath(repo, "/settings/audit")}"]))
    end
  end
end