Files
a2bf528ca0 feat(domain): introduce error slugs (#12030)
# Which Problems Are Solved

As part of https://github.com/zitadel/zitadel/issues/11917 we want to
introduce error slugs so (API) clients can rely on stable,
machine-readable errors and act accordingly.

# How the Problems Are Solved

- Added a `NewSlug` helper function in the domain package.
- Added `ErrorDetails` to the `ZitadelError`
- Added an `zitadel.error.v2.ErrorDetail` proto message
- Updated the connectRPC error interceptor to map new slug based errors
to the new `ErrorDetail`
- Defined some common slugs and error functions like internal errors
- Defined (session) specific slugs used in the `DeleteSession` and
`CheckUser` functions and replaced old implementations
- Updated integration tests to check specific errors if the relation
database feature is enabled
- Updated doc and guideline to reflect the latest changes and decisions
- Updated DeleteSession endpoint API to list possible slugs

# Additional Changes

None

# Additional Context

- closes #11957

---------

Co-authored-by: Wim Van Laer <wim+github@zitadel.com>
2026-04-13 09:47:04 +02:00

252 lines
8.2 KiB
Go

package integration
import (
"testing"
"time"
"github.com/pmezard/go-difflib/difflib"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc/gerrors"
errorpb "github.com/zitadel/zitadel/pkg/grpc/error/v2"
metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2"
resources_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha"
)
// Details is the interface that covers both v1 and v2 proto generated object details.
type Details interface {
comparable
GetSequence() uint64
GetCreationDate() *timestamppb.Timestamp
GetChangeDate() *timestamppb.Timestamp
GetResourceOwner() string
}
// DetailsMsg is the interface that covers all proto messages which contain v1 or v2 object details.
type DetailsMsg[D Details] interface {
GetDetails() D
}
type ListDetails interface {
comparable
GetTotalResult() uint64
GetTimestamp() *timestamppb.Timestamp
}
type ListDetailsMsg[L ListDetails] interface {
GetDetails() L
}
type ResourceListDetailsMsg interface {
GetDetails() *resources_object.ListDetails
}
// AssertDetails asserts values in a message's object Details,
// if the object Details in expected is a non-nil value.
// It targets API v2 messages that have the `GetDetails()` method.
//
// Dynamically generated values are not compared with expected.
// Instead, a sanity check is performed.
// For the sequence a non-zero value is expected.
// If the change date is populated, it is checked with a tolerance of 1 minute around Now.
//
// The resource owner is compared with expected.
func AssertDetails[D Details, M DetailsMsg[D]](t assert.TestingT, expected, actual M) {
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
var nilDetails D
if wantDetails == nilDetails {
assert.Nil(t, gotDetails)
return
}
assert.NotZero(t, gotDetails.GetSequence())
if wantDetails.GetCreationDate() != nil {
wantCreationDate := time.Now()
gotCreationDate := gotDetails.GetCreationDate().AsTime()
assert.WithinRange(t, gotCreationDate, wantCreationDate.Add(-time.Minute), wantCreationDate.Add(time.Minute))
}
if wantDetails.GetChangeDate() != nil {
wantChangeDate := time.Now()
gotChangeDate := gotDetails.GetChangeDate().AsTime()
assert.WithinRange(t, gotChangeDate, wantChangeDate.Add(-time.Minute), wantChangeDate.Add(time.Minute))
}
assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner())
}
func AssertResourceDetails(t assert.TestingT, expected *resources_object.Details, actual *resources_object.Details) {
if expected.GetChanged() != nil {
wantChangeDate := time.Now()
gotChangeDate := actual.GetChanged().AsTime()
assert.WithinRange(t, gotChangeDate, wantChangeDate.Add(-time.Minute), wantChangeDate.Add(time.Minute))
}
if expected.GetCreated() != nil {
wantCreatedDate := time.Now()
gotCreatedDate := actual.GetCreated().AsTime()
assert.WithinRange(t, gotCreatedDate, wantCreatedDate.Add(-time.Minute), wantCreatedDate.Add(time.Minute))
}
if expected.GetOwner() != nil {
expectedOwner := expected.GetOwner()
actualOwner := actual.GetOwner()
if !assert.NotNil(t, actualOwner) {
return
}
assert.Equal(t, expectedOwner.GetId(), actualOwner.GetId())
assert.Equal(t, expectedOwner.GetType(), actualOwner.GetType())
}
assert.NotEmpty(t, actual.GetId())
if expected.GetId() != "" {
assert.Equal(t, expected.GetId(), actual.GetId())
}
}
func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t assert.TestingT, expected, actual D) {
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
var nilDetails L
if wantDetails == nilDetails {
assert.Nil(t, gotDetails)
return
}
assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult())
if wantDetails.GetTimestamp() != nil {
gotCD := gotDetails.GetTimestamp().AsTime()
wantCD := time.Now()
assert.WithinRange(t, gotCD, wantCD.Add(-10*time.Minute), wantCD.Add(time.Minute))
}
}
func AssertResourceListDetails[D ResourceListDetailsMsg](t assert.TestingT, expected, actual D) {
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
if wantDetails == nil {
assert.Nil(t, gotDetails)
return
}
assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult())
assert.Equal(t, wantDetails.GetAppliedLimit(), gotDetails.GetAppliedLimit())
if wantDetails.GetTimestamp() != nil {
gotCD := gotDetails.GetTimestamp().AsTime()
wantCD := time.Now()
assert.WithinRange(t, gotCD, wantCD.Add(-10*time.Minute), wantCD.Add(time.Minute))
}
}
func AssertGrpcStatus(t assert.TestingT, expected codes.Code, err error) {
assert.Error(t, err)
statusErr, ok := status.FromError(err)
assert.True(t, ok)
assert.Equal(t, expected, statusErr.Code())
}
// EqualProto is inspired by [assert.Equal], only that it tests equality of a proto message.
// A message diff is printed on the error test log if the messages are not equal.
//
// As [assert.Equal] is based on reflection, comparing 2 proto messages sometimes fails,
// due to their internal state.
// Expected messages are usually with a vanilla state, e.g. only exported fields contain data.
// Actual messages obtained from the gRPC client had unexported fields with data.
// This makes them hard to compare.
func EqualProto(t testing.TB, expected, actual proto.Message) bool {
t.Helper()
if proto.Equal(expected, actual) {
return true
}
t.Errorf("Proto messages not equal: %s", diffProto(expected, actual))
return false
}
func diffProto(expected, actual proto.Message) string {
diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
A: difflib.SplitLines(protojson.Format(expected)),
B: difflib.SplitLines(protojson.Format(actual)),
FromFile: "Expected",
FromDate: "",
ToFile: "Actual",
ToDate: "",
Context: 1,
})
if err != nil {
panic(err)
}
return "\n\nDiff:\n" + diff
}
// AssertMetadataEquals verifies that two slices of proto Metadata are equal, comparing both length and content by key-value mapping.
func AssertMetadataEquals(t assert.TestingT, expected []*metadata.Metadata, actual []*metadata.Metadata) {
assert.Equal(t, len(expected), len(actual), "metadata length mismatch")
assert.Equal(t, getMetadataMap(expected), getMetadataMap(actual), "metadata content mismatch")
}
func getMetadataMap(metadataEntries []*metadata.Metadata) map[string][]byte {
metadataByKey := make(map[string][]byte, len(metadataEntries))
for _, md := range metadataEntries {
metadataByKey[md.Key] = md.Value
}
return metadataByKey
}
func RequireStatusError(t *testing.T, err, targetErr error) {
t.Helper()
if AssertStatusError(t, err, targetErr) {
return
}
t.FailNow()
}
func AssertStatusError(t *testing.T, err, targetErr error) bool {
t.Helper()
cErr, ok := status.FromError(err)
if !ok {
return assert.Fail(t, "expected status error")
}
tErr, ok := status.FromError(gerrors.ZITADELToGRPCError(t.Context(), targetErr))
if !ok {
return assert.Fail(t, "expected status error")
}
if !assert.Equal(t, tErr.Code(), cErr.Code(), "expected different status code") {
return false
}
if len(tErr.Details()) == 1 {
if len(cErr.Details()) != 1 {
return assert.Fail(t, "missing details")
}
targetDetails, ok := tErr.Details()[0].(*errorpb.ErrorDetail)
if !ok {
return assert.Failf(t, "expected error detail in target error", "got %#v", tErr.Details()[0])
}
details, ok := cErr.Details()[0].(*errorpb.ErrorDetail)
if !ok {
return assert.Failf(t, "expected error detail in actual error", "got %#v", cErr.Details()[0])
}
return AssertErrorDetail(t, details, targetDetails)
}
return true
}
func AssertErrorDetail(t *testing.T, detail, targetDetail *errorpb.ErrorDetail) bool {
t.Helper()
if !assert.Equal(t, targetDetail.GetSlug(), detail.GetSlug(), "expected different slug") {
return false
}
if targetDetail.GetMessage() != "" && !assert.Equal(t, targetDetail.GetMessage(), detail.GetMessage(), "expected different message") {
return false
}
if targetDetail.GetDetails() != nil && !assert.Equal(t, targetDetail.GetDetails().AsMap(), detail.GetDetails().AsMap(), "expected different details") {
return false
}
return true
}