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 }