8.5 KiB · text History b868907
defmodule GitGud.AuditTest do
  use GitGud.DataCase, async: true
  use Oban.Testing, repo: GitGud.Repo

  import ExUnit.CaptureLog

  require Logger
  import GitGud.ForgeFixtures

  alias GitGud.Accounts.Scope
  alias GitGud.Audit
  alias GitGud.Audit.Actor
  alias GitGud.Audit.Entry
  alias GitGud.Secrets

  setup do
    {user, repo} = repository_fixture()
    scope = user |> Scope.for_user() |> Scope.put_request({203, 0, 113, 9}, "curl/8.0")
    {:ok, user: user, repo: repo, scope: scope}
  end

  defp entries, do: Repo.all(from e in Entry, order_by: e.id)

  defp put_secret(repo, name), do: fn -> Secrets.put_repo_secret(repo, name, "S3CR3T-value") end

  describe "transact/5" do
    test "records the change with who, where and from what address", ctx do
      assert {:ok, _secret} =
               Audit.transact(ctx.scope, "repo_secret.set", put_secret(ctx.repo, "TOKEN"), fn _ ->
                 %{repository: ctx.repo, target_label: "TOKEN"}
               end)

      assert [entry] = entries()
      assert entry.action == "repo_secret.set"
      assert entry.category == "secrets"
      assert entry.target_type == "secret"
      assert entry.target_label == "TOKEN"
      assert entry.actor_type == "user"
      assert entry.actor_id == ctx.user.id
      assert entry.actor_label == ctx.user.handle
      assert entry.via == "web"
      assert entry.repository_id == ctx.repo.id
      assert entry.repository_label =~ "/#{ctx.repo.name}"
      assert entry.ip == "203.0.113.9"
      assert entry.user_agent == "curl/8.0"
    end

    test "a change that fails records nothing and returns what it returned", ctx do
      assert {:error, :nope} =
               Audit.transact(ctx.scope, "repo_secret.set", fn -> {:error, :nope} end, fn _ ->
                 %{repository: ctx.repo}
               end)

      assert :error =
               Audit.transact(ctx.scope, "repo_secret.set", fn -> :error end, fn _ -> %{} end)

      assert entries() == []
    end

    test ":ok passes through, and :skip records nothing but keeps the change", ctx do
      assert :ok =
               Audit.transact(ctx.scope, "repo_secret.deleted", fn -> :ok end, fn :ok -> %{} end)

      assert [_] = entries()

      assert {:ok, _} =
               Audit.transact(ctx.scope, "repo_secret.set", put_secret(ctx.repo, "KEPT"), fn _ ->
                 :skip
               end)

      assert [_] = entries()
      assert "KEPT" in Secrets.list_repo_secret_names(ctx.repo)
    end

    test "an entry that can't be written undoes the change", ctx do
      assert_raise Ecto.InvalidChangesetError, fn ->
        Audit.transact(ctx.scope, "repo_secret.set", put_secret(ctx.repo, "GONE"), fn _ ->
          # actor_label is required; a blank one can't be inserted.
          %{repository: ctx.repo, actor_label: ""}
        end)
      end

      refute "GONE" in Secrets.list_repo_secret_names(ctx.repo)
      assert entries() == []
    end

    test "an unknown action raises before the change runs", ctx do
      assert_raise ArgumentError, ~r/unknown audit action/, fn ->
        Audit.transact(ctx.scope, "repo_secret.stolen", fn -> flunk("ran") end, fn _ -> %{} end)
      end
    end

    test "an anonymous scope can't be an actor", ctx do
      assert_raise ArgumentError, fn ->
        Audit.transact(struct(Scope, user: nil), "repo_secret.set", fn -> :ok end, fn _ ->
          %{repository: ctx.repo}
        end)
      end
    end

    test "record: :before writes the entry first, and a failure takes it back", ctx do
      assert :ok =
               Audit.transact(
                 ctx.scope,
                 "repo_secret.deleted",
                 fn -> :ok end,
                 fn :before -> %{} end,
                 record: :before
               )

      assert [_] = entries()

      assert {:error, :failed} =
               Audit.transact(
                 ctx.scope,
                 "repo_secret.deleted",
                 fn -> {:error, :failed} end,
                 fn :before -> %{} end,
                 record: :before
               )

      assert [_] = entries()
    end

    test "a committed entry leaves one log line, without the metadata", ctx do
      # Test config logs from :warning; let this module's notices through.
      Logger.put_module_level(Audit, :notice)
      on_exit(fn -> Logger.delete_module_level(Audit) end)

      log =
        capture_log([level: :notice], fn ->
          Audit.transact(ctx.scope, "repo_secret.set", put_secret(ctx.repo, "LOGGED"), fn _ ->
            %{repository: ctx.repo, target_label: "LOGGED"}
          end)
        end)

      assert log =~ "audit: repo_secret.set by #{ctx.user.handle}"
      assert log =~ "LOGGED in "
      refute log =~ "S3CR3T"
    end

    test "nothing is federated", ctx do
      Audit.transact(ctx.scope, "repo_secret.set", put_secret(ctx.repo, "LOCAL"), fn _ ->
        %{repository: ctx.repo}
      end)

      refute_enqueued(worker: GitGud.Federation.Workers.DeliverOutbound)
    end
  end

  describe "metadata" do
    test "only the catalog's keys, and only plain values, are kept" do
      log =
        capture_log(fn ->
          Audit.record(Actor.system(:cron), "audit.pruned", %{
            metadata: %{count: 3, cutoff: ~U[2026-01-01 00:00:00Z], value: "S3CR3T", other: %{}}
          })
        end)

      assert [entry] = entries()
      assert entry.metadata == %{"count" => 3, "cutoff" => "2026-01-01T00:00:00Z"}
      assert log =~ ~s(dropped metadata key "value")
      refute log =~ "S3CR3T"
    end

    test "booleans and nil survive; nested maps and long strings don't, whole", ctx do
      Audit.insert!(ctx.scope, "audit.pruned", %{
        metadata: %{count: false, cutoff: String.duplicate("x", 500)}
      })

      assert [%{metadata: %{"count" => false, "cutoff" => cutoff}}] = entries()
      assert String.length(cutoff) == 200
    end
  end

  describe "labels" do
    test "an entry outlives the repository it's about", ctx do
      entry =
        Audit.record(ctx.scope, "repo_secret.set", %{repository: ctx.repo, target_label: "X"})

      label = entry.repository_label

      Repo.delete!(ctx.repo)

      assert %Entry{repository_id: nil, repository_label: ^label} = Repo.get!(Entry, entry.id)
    end

    test "a repository's organization is filled in" do
      user = GitGud.AccountsFixtures.user_fixture()

      {:ok, org} =
        GitGud.Organizations.create_organization(user, %{
          "handle" => "audit-org-#{System.unique_integer([:positive])}"
        })

      {:ok, repo} =
        GitGud.Repositories.create_repository_for_org(org, user, %{"name" => "in-org"})

      entry = Audit.record(Scope.for_user(user), "repo_secret.set", %{repository: repo})

      assert entry.organization_id == org.id
      assert entry.organization_label == "@#{org.handle}"
    end
  end

  describe "list/4" do
    setup ctx do
      ids =
        for i <- 1..5 do
          Audit.record(ctx.scope, "repo_secret.set", %{
            repository: ctx.repo,
            target_label: "S#{i}"
          }).id
        end

      {:ok, ids: ids}
    end

    test "newest first, paged by id in both directions", %{repo: repo, ids: ids} do
      [i1, i2, i3, i4, i5] = ids

      assert {[%{id: ^i5}, %{id: ^i4}], %{older: ^i4, newer: nil}} =
               Audit.list({:repo, repo}, %{}, nil, 2)

      assert {[%{id: ^i3}, %{id: ^i2}], %{older: ^i2, newer: ^i3}} =
               Audit.list({:repo, repo}, %{}, {:before, i4}, 2)

      assert {[%{id: ^i1}], %{older: nil, newer: ^i1}} =
               Audit.list({:repo, repo}, %{}, {:before, i2}, 2)

      assert {[%{id: ^i3}, %{id: ^i2}], %{older: ^i2, newer: ^i3}} =
               Audit.list({:repo, repo}, %{}, {:after, i1}, 2)
    end

    test "filters by category and stays inside its scope", %{repo: repo} do
      {_other_user, other_repo} = repository_fixture()

      assert {[], _} = Audit.list({:repo, other_repo})
      assert {[_ | _], _} = Audit.list({:repo, repo}, %{category: "secrets"})
      assert {[], _} = Audit.list({:repo, repo}, %{category: "instance"})
    end
  end

  describe "prune/2 and retention" do
    test "deletes only entries older than the cutoff, in batches", ctx do
      old = DateTime.add(DateTime.utc_now(), -10 * 86_400, :second)

      for _ <- 1..5 do
        ctx.scope
        |> Audit.record("repo_secret.set", %{repository: ctx.repo})
        |> Ecto.Changeset.change(inserted_at: old)
        |> Repo.update!()
      end

      kept = Audit.record(ctx.scope, "repo_secret.set", %{repository: ctx.repo})

      assert Audit.prune(DateTime.add(DateTime.utc_now(), -86_400, :second), 2) == 5
      assert [%{id: id}] = entries()
      assert id == kept.id
    end
  end
end