5.6 KiB · text History b868907
defmodule GitGudWeb.RepoControlsAuditTest do
  @moduledoc """
  A repository's controls leave audit entries: branch protection, deploy
  keys, webhooks (the repository's and the org's), registry tokens and
  job-token grants. Never a key body, a token, a webhook secret or the
  path of a webhook URL.
  """
  use GitGudWeb.ConnCase, async: true

  import Ecto.Query
  import Phoenix.LiveViewTest
  import GitGud.ForgeFixtures

  alias GitGud.Audit.Entry
  alias GitGud.Organizations
  alias GitGud.Repositories.Storage

  @key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIvCK0qd2v15O96oXcAYO3FpyDC2OYa3LxOoehl0HUx ci"

  setup %{conn: conn} do
    {owner, repo} = repository_fixture()
    repo = GitGud.Repo.preload(repo, [:owner, :organization])

    {:ok,
     owner: owner,
     repo: repo,
     conn: log_in_user(conn, owner),
     base: "/r/#{Storage.repo_handle(repo)}/#{repo.name}/settings"}
  end

  defp entries(repo),
    do: GitGud.Repo.all(from e in Entry, where: e.repository_id == ^repo.id, order_by: e.id)

  defp stored, do: GitGud.Repo.all(Entry) |> Enum.map(&Map.from_struct/1) |> inspect()

  test "branch protection", %{conn: conn, repo: repo, base: base} do
    {:ok, lv, _} = live(conn, base <> "/branches")

    lv
    |> form("#bp-form", %{
      "branch_protection" => %{"pattern" => "release/*", "required_approvals" => "2"}
    })
    |> render_submit()

    [rule] = GitGud.BranchProtections.list_rules(repo)
    lv |> element("#delete-rule-#{rule.id}") |> render_click()

    assert [added, deleted] = entries(repo)
    assert {added.action, added.target_label} == {"branch_protection.created", "release/*"}
    assert added.metadata["required_approvals"] == 2
    assert {deleted.action, deleted.target_label} == {"branch_protection.deleted", "release/*"}
  end

  test "deploy keys, by title and fingerprint", %{conn: conn, repo: repo, base: base} do
    {:ok, lv, _} = live(conn, base <> "/deploy-keys")

    lv
    |> form("#deploy-key-form", %{
      "deploy_key" => %{"title" => "ci", "key" => @key, "read_write" => "true"}
    })
    |> render_submit()

    [key] = GitGud.DeployKeys.list_keys(repo)
    lv |> element("#delete-deploy-key-#{key.id}") |> render_click()

    assert [added, removed] = entries(repo)
    assert added.action == "deploy_key.added"
    assert added.metadata == %{"fingerprint" => key.fingerprint, "read_write" => true}
    assert {removed.action, removed.target_label} == {"deploy_key.removed", "ci"}
    refute stored() =~ "AAAAC3NzaC1lZDI1NTE5AAAAIDIvCK0q"
  end

  describe "webhooks" do
    test "a repository's: the host, never the path or the secret", %{
      conn: conn,
      repo: repo,
      base: base
    } do
      {:ok, lv, _} = live(conn, base <> "/webhooks")

      lv
      |> form("#webhook-form", %{
        "webhook" => %{"url" => "https://hooks.example/T000/B000/XYZTOKEN"}
      })
      |> render_submit()

      [hook] = GitGud.Webhooks.list_webhooks(repo)
      lv |> element("#toggle-webhook-#{hook.id}") |> render_click()
      lv |> element("#toggle-webhook-#{hook.id}") |> render_click()
      lv |> element("#delete-webhook-#{hook.id}") |> render_click()

      assert ~w(webhook.created webhook.disabled webhook.enabled webhook.deleted) ==
               Enum.map(entries(repo), & &1.action)

      assert Enum.all?(entries(repo), &(&1.target_label == "hooks.example"))
      refute stored() =~ "XYZTOKEN"
      refute stored() =~ Base.encode64(hook.secret)
    end

    test "an org's", %{conn: conn, owner: owner} do
      handle = "wh#{System.unique_integer([:positive])}"

      {:ok, org} =
        Organizations.create_organization(owner, %{"handle" => handle, "name" => handle})

      {:ok, lv, _} = live(conn, ~p"/orgs/#{handle}/settings/webhooks")

      lv
      |> form("#webhook-form", %{"webhook" => %{"url" => "https://ci.example/hook/abc"}})
      |> render_submit()

      assert [%Entry{action: "webhook.created", organization_id: oid, target_label: host}] =
               GitGud.Repo.all(from e in Entry, where: e.organization_id == ^org.id)

      assert {oid, host} == {org.id, "ci.example"}
    end
  end

  test "registry tokens, never the token", %{conn: conn, repo: repo, base: base} do
    {:ok, lv, _} = live(conn, base <> "/registry-tokens")

    html =
      lv
      |> form("form[phx-submit=create_token]", %{"name" => "argo-pull", "scopes" => ["pull"]})
      |> render_submit()

    [plaintext] = Regex.run(~r/ggrt_[A-Za-z0-9_-]+/, html)
    [token] = GitGud.Packages.list_registry_tokens(repo)
    render_click(lv, "delete_token", %{"id" => to_string(token.id)})
    # Already gone: nothing more to record.
    render_click(lv, "delete_token", %{"id" => to_string(token.id)})

    assert [created, revoked] = entries(repo)
    assert {created.target_label, created.metadata} == {"argo-pull", %{"scopes" => ["pull"]}}
    assert revoked.action == "registry_token.revoked"
    refute stored() =~ plaintext
  end

  test "job-token grants", %{conn: conn, repo: repo, base: base} do
    {_, source} = repository_fixture()
    source = GitGud.Repo.preload(source, [:owner, :organization])
    label = "#{Storage.repo_handle(source)}/#{source.name}"
    {:ok, lv, _} = live(conn, base <> "/job-tokens")

    lv
    |> form("form[phx-submit=create_grant]", %{"source" => label, "read" => "true"})
    |> render_submit()

    [grant] = GitGud.Workflows.JobTokenGrants.list(repo)
    lv |> element("button[phx-value-id='#{grant.id}']") |> render_click()

    assert [created, deleted] = entries(repo)
    assert created.target_label == label
    assert created.metadata == %{"read" => true, "dispatch" => false}
    assert {deleted.action, deleted.target_label} == {"job_token_grant.deleted", label}
  end
end