Files
gitea/services/audit/audit_test.go
bircni da37b7916b feat: Add audit logging (#38189)
Co-authored-by: bircni <bircni@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-12 08:15:23 +00:00

280 lines
9.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"testing"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/web/middleware"
"github.com/stretchr/testify/assert"
)
func BenchmarkRecordDisabled(b *testing.B) {
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDisabled)()
ctx := context.Background()
u := &user_model.User{ID: 1, Name: "user"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
RecordAs(ctx, u, audit_model.UserPassword, u)
}
}
func BenchmarkBuildEvent(b *testing.B) {
params := RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "actor"},
Scope: audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"},
Metadata: map[string]any{
"remote_address": "https://example.com/repo.git",
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = buildEvent(context.Background(), params)
}
}
// newRequestContext mimics what routers/common.AuthShared publishes for a
// signed-in request.
func newRequestContext(t *testing.T, signedIn *user_model.User) context.Context {
t.Helper()
rc := reqctx.NewRequestContextForTest(t)
rc.GetData()[middleware.ContextDataKeySignedUser] = signedIn
return rc
}
func TestBuildEvent(t *testing.T) {
doer := &user_model.User{ID: 2, Name: "Doer"}
u := &user_model.User{ID: 1, Name: "TestUser"}
t.Run("MessageFromTemplate", func(t *testing.T) {
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserCreate,
Actor: actorRef(doer),
Scope: ScopeFromUser(u),
})
assert.Equal(t, audit_model.UserCreate, e.Action)
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 2, Name: "Doer"}, e.Actor())
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}, e.Scope())
assert.Equal(t, "Created user TestUser.", e.Message)
})
t.Run("MetadataFillsPlaceholders", func(t *testing.T) {
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: actorRef(doer),
Scope: ScopeFromRepository(r),
Metadata: metaPairs(
"mirror_id", m.ID,
"remote_address", m.RemoteAddress,
),
})
assert.Equal(t, "TestUser/TestRepo", e.ScopeName)
assert.Equal(t, "Added push mirror to git@example.com:repo.git for repository TestUser/TestRepo.", e.Message)
assert.InDelta(t, float64(m.ID), audit_model.DecodeMetadata(e.Metadata)["mirror_id"], 0)
})
t.Run("StatusChangesIncludeTheirNewValue", func(t *testing.T) {
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserRestricted,
Actor: actorRef(doer),
Scope: ScopeFromUser(u),
Metadata: metaPairs("restricted", true),
})
assert.Equal(t, "Changed restricted status of user TestUser to true.", e.Message)
})
t.Run("SystemActorNamesTaskOrKey", func(t *testing.T) {
actions := user_model.NewActionsUserWithTaskID(42)
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserCreate,
Actor: actorRef(actions),
ActorCredential: actorCredential(context.Background(), actions),
Scope: ScopeFromUser(u),
})
assert.Equal(t, user_model.ActionsUserID, e.ActorID)
assert.Equal(t, "gitea-actions:42", e.ActorCredential)
key := user_model.NewDeployKeyUserWithKeyID(7)
assert.Equal(t, "deploy-key:7", actorCredential(context.Background(), key))
assert.Empty(t, actorCredential(context.Background(), doer))
})
t.Run("CredentialFromRequest", func(t *testing.T) {
ctx := newRequestContext(t, doer)
middleware.GetContextData(ctx)[middleware.ContextDataKeyAuthCredential] = "access-token:9"
assert.Equal(t, "access-token:9", actorCredential(ctx, doer))
// an event recorded for someone other than the signed-in user is not
// tied to the credential of that request
assert.Empty(t, actorCredential(ctx, u))
})
t.Run("RequestInfo", func(t *testing.T) {
params := RecordParams{Action: audit_model.UserCreate, Actor: actorRef(doer), Scope: ScopeFromUser(u)}
e := buildEvent(context.Background(), params)
assert.Empty(t, e.IPAddress)
assert.Equal(t, audit_model.OriginSystem, e.Origin)
cliCtx := WithOrigin(context.Background(), audit_model.OriginCLI)
assert.Equal(t, audit_model.OriginCLI, buildEvent(cliCtx, params).Origin)
apiCtx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(apiCtx, audit_model.OriginAPI, "127.0.0.1")
e = buildEvent(apiCtx, params)
assert.Equal(t, "127.0.0.1", e.IPAddress)
assert.Equal(t, audit_model.OriginAPI, e.Origin)
// an explicit origin wins over the one of the surrounding request
systemAPIContext := WithOrigin(apiCtx, audit_model.OriginSystem)
assert.Equal(t, audit_model.OriginSystem, buildEvent(systemAPIContext, params).Origin)
})
}
func TestEntityRefDisplay(t *testing.T) {
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}
assert.Equal(t, "TestUser", ref.DisplayName())
assert.Equal(t, "/TestUser", ref.HomeLink())
assert.True(t, ref.HasLink())
sys := ScopeSystem()
assert.Equal(t, "System", sys.DisplayName())
assert.Empty(t, sys.HomeLink())
assert.False(t, sys.HasLink())
// a scope whose entity was deleted keeps its ID but has no name to link to
deleted := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3}
assert.Empty(t, deleted.DisplayName())
assert.False(t, deleted.HasLink())
repo := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3, Name: "Test User/Test Repo"}
assert.Equal(t, "/Test%20User/Test%20Repo", repo.HomeLink())
assert.True(t, repo.HasLink())
}
func TestEncodeDecodeMetadata(t *testing.T) {
raw := audit_model.EncodeMetadata(metaPairs("repo_id", int64(42), "repo", "o/r"))
decoded := audit_model.DecodeMetadata(raw)
assert.InDelta(t, 42.0, decoded["repo_id"], 0) // json numbers decode as float64
assert.Equal(t, "o/r", decoded["repo"])
}
func TestDoerFromContext(t *testing.T) {
doer := &user_model.User{ID: 2, Name: "Doer"}
signedIn := &user_model.User{ID: 3, Name: "SignedIn"}
t.Run("NoActor", func(t *testing.T) {
assert.Nil(t, doerFromContext(context.Background()))
})
t.Run("WithDoer", func(t *testing.T) {
assert.Equal(t, doer, doerFromContext(WithDoer(context.Background(), doer)))
})
t.Run("SignedInUserOfRequest", func(t *testing.T) {
ctx := newRequestContext(t, signedIn)
assert.Equal(t, signedIn, doerFromContext(ctx))
})
t.Run("WithDoerWinsOverSignedInUser", func(t *testing.T) {
ctx := WithDoer(newRequestContext(t, signedIn), doer)
assert.Equal(t, doer, doerFromContext(ctx))
})
}
// An impersonated session must never pin an event on the impersonated user
// alone, otherwise an admin could act in someone else's name untraceably.
func TestImpersonatorRef(t *testing.T) {
admin := &user_model.User{ID: 1, Name: "Admin"}
impersonated := &user_model.User{ID: 2, Name: "Impersonated"}
rc := reqctx.NewRequestContextForTest(t)
rc.GetData()[middleware.ContextDataKeySignedUser] = impersonated
rc.GetData()[middleware.ContextDataKeyImpersonator] = admin
assert.Equal(t, admin, ImpersonatorFromContext(rc))
e := buildEvent(rc, RecordParams{
Action: audit_model.UserPassword,
Actor: actorRef(doerFromContext(rc)),
Impersonator: impersonatorRef(ImpersonatorFromContext(rc), doerFromContext(rc)),
Scope: ScopeFromUser(impersonated),
})
assert.Equal(t, int64(2), e.ActorID)
assert.Equal(t, &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Admin"}, e.Impersonator())
// an admin acting as themselves is not an impersonation
assert.Nil(t, impersonatorRef(admin, admin))
assert.Nil(t, impersonatorRef(nil, impersonated))
}
// An unresolvable actor must still produce an event, so a missing entry point
// never silently drops security relevant records.
func TestActorRefWithoutDoer(t *testing.T) {
ref := actorRef(nil)
assert.Equal(t, "Unknown", ref.DisplayName())
assert.False(t, ref.HasLink())
}
func TestRenderMessage(t *testing.T) {
actor := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Actor"}
scope := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"}
t.Run("EveryActionHasATemplate", func(t *testing.T) {
for _, action := range audit_model.AllActions() {
tmpl, ok := audit_model.MessageTemplate(action)
assert.True(t, ok, "action %q has no message template", action)
assert.NotEmpty(t, tmpl, "action %q has empty message template", action)
}
})
t.Run("ReservedPlaceholders", func(t *testing.T) {
assert.Equal(t,
"User Actor started impersonating user owner/repo.",
renderMessage(audit_model.UserImpersonation, actor, scope, nil),
)
})
t.Run("NonStringMetadata", func(t *testing.T) {
assert.Equal(t,
"Removed external login from authentication source 7 for user owner/repo.",
renderMessage(audit_model.UserExternalLoginRemove, actor, scope, map[string]any{"auth_source_id": int64(7)}),
)
})
t.Run("MissingMetadataKeepsTheKey", func(t *testing.T) {
assert.Equal(t,
"Added deploy key deploy_key for repository owner/repo.",
renderMessage(audit_model.RepositoryDeployKeyAdd, actor, scope, nil),
)
})
t.Run("UnknownActionFallsBackToItsName", func(t *testing.T) {
assert.Equal(t, "not:an:action", renderMessage(audit_model.Action("not:an:action"), actor, scope, nil))
})
}
func TestActionFilters(t *testing.T) {
assert.True(t, audit_model.IsActionFilter("user:impersonation"))
assert.True(t, audit_model.IsActionFilter(audit_model.UserImpersonation))
assert.False(t, audit_model.IsActionFilter("user:unknown"))
}