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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
8.5 KiB · text
History
8ccf537