diff --git a/service/entityresolution/integration/README.md b/service/entityresolution/integration/README.md index 0c20dbd047..1257ceb7df 100644 --- a/service/entityresolution/integration/README.md +++ b/service/entityresolution/integration/README.md @@ -87,10 +87,24 @@ adapter := NewMultiStrategyTestAdapter() ``` **Supported Providers:** -- JWT Claims Provider (used in tests) +- JWT Claims Provider - SQL Provider (SQLite/PostgreSQL support) - LDAP Provider (enterprise directory integration) +#### Multi-strategy provider contract matrix + +`internal/resolved_token_chain_contract.go` owns the shared token-chain behavior. +Provider adapters in `multistrategy_provider_contract_test.go` supply setup, +teardown, normal and reversed-strategy service construction, configuration, token +fixtures, and expected mapped fields. The suite runs the same +multi-entity chain scenarios for claims, SQL, and LDAP with both environment→subject +and subject→environment strategy order, single and multiple tokens, collection-valued +context, and fail-closed mixed valid/invalid token batches. + +When adding a provider, enroll one adapter to receive the existing contract scenarios. +When adding a provider-independent token-chain behavior, add it once to +`ResolvedTokenChainContractSuite` so every enrolled provider runs it. + **Strategy Testing:** - Multiple mapping strategies with conditions - JWT claim matching and processing diff --git a/service/entityresolution/integration/internal/resolved_token_chain_contract.go b/service/entityresolution/integration/internal/resolved_token_chain_contract.go new file mode 100644 index 0000000000..053b451dc7 --- /dev/null +++ b/service/entityresolution/integration/internal/resolved_token_chain_contract.go @@ -0,0 +1,143 @@ +package internal + +import ( + "context" + "reflect" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +const resolvedTokenChainCleanupTimeout = 30 * time.Second + +// ResolvedTokenChainEntityExpectation describes one expected entity in a resolved chain. +type ResolvedTokenChainEntityExpectation struct { + ExpectedClaims map[string]interface{} + Category entity.Entity_Category +} + +// ResolvedTokenChainExpectation describes the final mapped context expected for one token. +type ResolvedTokenChainExpectation struct { + Token *entity.Token + Entities []ResolvedTokenChainEntityExpectation +} + +// ResolvedTokenChainAdapter enrolls an ERS/provider configuration in the shared +// token-chain contract. Implementations provide setup and fixtures; the suite owns behavior. +type ResolvedTokenChainAdapter interface { + ERSTestAdapter + CreateERSServiceWithReversedStrategies(context.Context) (ERSImplementation, error) + ResolvedTokenChainExpectations(*ContractTestDataSet) []ResolvedTokenChainExpectation +} + +// ResolvedTokenChainContractSuite validates provider-independent token-chain behavior. +type ResolvedTokenChainContractSuite struct{} + +func NewResolvedTokenChainContractSuite() *ResolvedTokenChainContractSuite { + return &ResolvedTokenChainContractSuite{} +} + +func (suite *ResolvedTokenChainContractSuite) RunWithAdapter(t *testing.T, adapter ResolvedTokenChainAdapter) { + t.Helper() + ctx := t.Context() + dataSet := NewContractTestDataSet() + + require.NoError(t, adapter.SetupTestData(ctx, dataSet)) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), resolvedTokenChainCleanupTimeout) + defer cancel() + require.NoError(t, adapter.TeardownTestData(cleanupCtx)) + }) + + implementation, err := adapter.CreateERSService(ctx) + require.NoError(t, err) + + expectations := adapter.ResolvedTokenChainExpectations(dataSet) + require.NotEmpty(t, expectations) + + t.Run(adapter.GetScopeName()+"_EnvironmentThenSubjectPreservesMultiEntityMappedContext", func(t *testing.T) { + suite.assertResolvedTokenChains(t, implementation, expectations[:1]) + }) + + reversedImplementation, err := adapter.CreateERSServiceWithReversedStrategies(ctx) + require.NoError(t, err) + t.Run(adapter.GetScopeName()+"_SubjectThenEnvironmentPreservesMultiEntityMappedContext", func(t *testing.T) { + suite.assertResolvedTokenChains(t, reversedImplementation, expectations[:1]) + }) + + if len(expectations) > 1 { + t.Run(adapter.GetScopeName()+"_MultipleTokensPreserveMultiEntityMappedContext", func(t *testing.T) { + suite.assertResolvedTokenChains(t, implementation, expectations) + }) + } + + t.Run(adapter.GetScopeName()+"_MixedValidInvalidTokenBatchFailsClosed", func(t *testing.T) { + resp, err := implementation.CreateEntityChainsFromTokens(t.Context(), connect.NewRequest(&entityresolutionV2.CreateEntityChainsFromTokensRequest{ + Tokens: []*entity.Token{expectations[0].Token, {EphemeralId: "invalid-token", Jwt: "not-a-jwt"}}, + })) + require.Error(t, err) + require.Nil(t, resp, "failed batch must not return partial chains") + }) +} + +func (suite *ResolvedTokenChainContractSuite) assertResolvedTokenChains( + t *testing.T, + implementation ERSImplementation, + expectations []ResolvedTokenChainExpectation, +) { + t.Helper() + + tokens := make([]*entity.Token, 0, len(expectations)) + byTokenID := make(map[string]ResolvedTokenChainExpectation, len(expectations)) + for _, expectation := range expectations { + tokens = append(tokens, expectation.Token) + byTokenID[expectation.Token.GetEphemeralId()] = expectation + } + + resp, err := implementation.CreateEntityChainsFromTokens(t.Context(), connect.NewRequest(&entityresolutionV2.CreateEntityChainsFromTokensRequest{ + Tokens: tokens, + })) + require.NoError(t, err) + require.Len(t, resp.Msg.GetEntityChains(), len(expectations)) + + for _, chain := range resp.Msg.GetEntityChains() { + expectation, ok := byTokenID[chain.GetEphemeralId()] + require.True(t, ok, "unexpected or duplicate chain %q", chain.GetEphemeralId()) + delete(byTokenID, chain.GetEphemeralId()) + require.Len(t, chain.GetEntities(), len(expectation.Entities)) + + for _, expectedEntity := range expectation.Entities { + matched := false + for _, chained := range chain.GetEntities() { + if chained.GetCategory() != expectedEntity.Category { + continue + } + claims := chained.GetClaims() + require.NotNil(t, claims, "resolved chain entity must carry mapped claims") + + var claimsStruct structpb.Struct + require.NoError(t, claims.UnmarshalTo(&claimsStruct)) + if containsExpectedClaims(claimsStruct.AsMap(), expectedEntity.ExpectedClaims) { + matched = true + break + } + } + require.True(t, matched, "chain %q did not contain category %s with mapped claims %v", chain.GetEphemeralId(), expectedEntity.Category, expectedEntity.ExpectedClaims) + } + } + require.Empty(t, byTokenID, "response omitted one or more requested token chains") +} + +func containsExpectedClaims(actual, expected map[string]interface{}) bool { + for key, expectedValue := range expected { + if actualValue, ok := actual[key]; !ok || !reflect.DeepEqual(actualValue, expectedValue) { + return false + } + } + return true +} diff --git a/service/entityresolution/integration/ldap_test_data/02_test_users.ldif b/service/entityresolution/integration/ldap_test_data/02_test_users.ldif index 8ec2d42301..411541aee7 100644 --- a/service/entityresolution/integration/ldap_test_data/02_test_users.ldif +++ b/service/entityresolution/integration/ldap_test_data/02_test_users.ldif @@ -15,6 +15,8 @@ mail: alice@opentdf.test userPassword: alice_password employeeNumber: ENG001 departmentNumber: engineering +businessCategory: engineering +businessCategory: developers title: Software Engineer description: Frontend developer specializing in React and TypeScript telephoneNumber: +1-555-0101 @@ -34,6 +36,8 @@ mail: bob@opentdf.test userPassword: bob_password employeeNumber: MKT001 departmentNumber: marketing +businessCategory: marketing +businessCategory: campaigns title: Product Manager description: Product manager focusing on developer tools and APIs telephoneNumber: +1-555-0201 diff --git a/service/entityresolution/integration/multistrategy_provider_contract_test.go b/service/entityresolution/integration/multistrategy_provider_contract_test.go new file mode 100644 index 0000000000..9586105fd6 --- /dev/null +++ b/service/entityresolution/integration/multistrategy_provider_contract_test.go @@ -0,0 +1,252 @@ +package integration + +import ( + "context" + "database/sql" + "path/filepath" + "slices" + "testing" + + "github.com/opentdf/platform/protocol/go/entity" + "github.com/opentdf/platform/service/entityresolution/integration/internal" + "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" + multistrategyv2 "github.com/opentdf/platform/service/entityresolution/multi-strategy/v2" + "github.com/opentdf/platform/service/logger" + "github.com/testcontainers/testcontainers-go" + + _ "github.com/mattn/go-sqlite3" +) + +type multiStrategyProviderContractAdapter struct { + name string + config types.MultiStrategyConfig + expectations []internal.ResolvedTokenChainExpectation + setup func(context.Context) error + teardown func(context.Context) error +} + +func (a *multiStrategyProviderContractAdapter) GetScopeName() string { + return a.name +} + +func (a *multiStrategyProviderContractAdapter) SetupTestData(ctx context.Context, _ *internal.ContractTestDataSet) error { + if a.setup == nil { + return nil + } + return a.setup(ctx) +} + +func (a *multiStrategyProviderContractAdapter) CreateERSService(ctx context.Context) (internal.ERSImplementation, error) { + return multistrategyv2.NewERSV2(ctx, a.config, logger.CreateTestLogger()) +} + +func (a *multiStrategyProviderContractAdapter) CreateERSServiceWithReversedStrategies(ctx context.Context) (internal.ERSImplementation, error) { + config := a.config + config.MappingStrategies = slices.Clone(a.config.MappingStrategies) + slices.Reverse(config.MappingStrategies) + return multistrategyv2.NewERSV2(ctx, config, logger.CreateTestLogger()) +} + +func (a *multiStrategyProviderContractAdapter) TeardownTestData(ctx context.Context) error { + if a.teardown == nil { + return nil + } + return a.teardown(ctx) +} + +func (a *multiStrategyProviderContractAdapter) ResolvedTokenChainExpectations(_ *internal.ContractTestDataSet) []internal.ResolvedTokenChainExpectation { + return a.expectations +} + +func TestMultiStrategyProviderResolvedTokenChainContract(t *testing.T) { + if testing.Short() { + t.Skip("Skipping multi-strategy provider contract tests in short mode") + } + + adapters := []internal.ResolvedTokenChainAdapter{ + newClaimsProviderContractAdapter(), + newSQLProviderContractAdapter(t), + newLDAPProviderContractAdapter(t), + } + suite := internal.NewResolvedTokenChainContractSuite() + for _, adapter := range adapters { + adapter := adapter + t.Run(adapter.GetScopeName(), func(t *testing.T) { + suite.RunWithAdapter(t, adapter) + }) + } +} + +func newClaimsProviderContractAdapter() *multiStrategyProviderContractAdapter { + return &multiStrategyProviderContractAdapter{ + name: "ClaimsProvider", + config: types.MultiStrategyConfig{ + FailureStrategy: types.FailureStrategyContinue, + Providers: map[string]types.ProviderConfig{ + "claims": {Type: "claims", Connection: map[string]interface{}{}}, + }, + MappingStrategies: []types.MappingStrategy{ + environmentContractStrategy("claims"), + { + Name: "claims_subject", + Provider: "claims", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{JWTClaims: []types.JWTClaimCondition{{Claim: "sub", Operator: "exists"}}}, + OutputMapping: []types.OutputMapping{ + {SourceClaim: "sub", ClaimName: "username"}, + {SourceClaim: "email", ClaimName: "email"}, + {SourceClaim: "department", ClaimName: "department"}, + {SourceClaim: "groups", ClaimName: "groups"}, + }, + }, + }, + }, + expectations: providerContractExpectations("claims", "alice@opentdf.test", "engineering", []interface{}{"engineering", "developers"}, "bob@opentdf.test", "marketing", []interface{}{"marketing", "campaigns"}), + } +} + +func newSQLProviderContractAdapter(t *testing.T) *multiStrategyProviderContractAdapter { + t.Helper() + databasePath := filepath.Join(t.TempDir(), "ers-provider-contract.db") + adapter := &multiStrategyProviderContractAdapter{ + name: "SQLProvider", + config: types.MultiStrategyConfig{ + FailureStrategy: types.FailureStrategyContinue, + Providers: map[string]types.ProviderConfig{ + "chain_context": {Type: "claims", Connection: map[string]interface{}{}}, + "sql": {Type: "sql", Connection: map[string]interface{}{ + "driver": "sqlite3", "database": databasePath, + }}, + }, + MappingStrategies: []types.MappingStrategy{ + environmentContractStrategy("chain_context"), + { + Name: "sql_subject", + Provider: "sql", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{JWTClaims: []types.JWTClaimCondition{{Claim: "sub", Operator: "exists"}}}, + Query: "SELECT username, email, department, groups_csv FROM users WHERE username = ?", + InputMapping: []types.InputMapping{{ + JWTClaim: "sub", Parameter: "username", Required: true, + }}, + OutputMapping: []types.OutputMapping{ + {SourceColumn: "username", ClaimName: "username"}, + {SourceColumn: "email", ClaimName: "email"}, + {SourceColumn: "department", ClaimName: "department"}, + {SourceColumn: "groups_csv", ClaimName: "groups", Transformation: "csv_to_array"}, + }, + }, + }, + }, + expectations: providerContractExpectations("sql", "alice@opentdf.test", "engineering", []interface{}{"engineering", "developers"}, "bob@opentdf.test", "marketing", []interface{}{"marketing", "campaigns"}), + } + adapter.setup = func(context.Context) error { + db, err := sql.Open("sqlite3", databasePath) + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec(` + CREATE TABLE users (username TEXT PRIMARY KEY, email TEXT, department TEXT, groups_csv TEXT); + INSERT INTO users VALUES ('alice', 'alice@opentdf.test', 'engineering', 'engineering,developers'); + INSERT INTO users VALUES ('bob', 'bob@opentdf.test', 'marketing', 'marketing,campaigns'); + `) + return err + } + return adapter +} + +func newLDAPProviderContractAdapter(t *testing.T) *multiStrategyProviderContractAdapter { + t.Helper() + adapter := &multiStrategyProviderContractAdapter{ + name: "LDAPProvider", + expectations: providerContractExpectations("ldap", "alice@opentdf.test", "engineering", []interface{}{"engineering", "developers"}, "bob@opentdf.test", "marketing", []interface{}{"marketing", "campaigns"}), + } + var container testcontainers.Container + adapter.setup = func(ctx context.Context) error { + var host string + var port int + container, host, port = startSeededLDAPContainer(ctx, t) + adapter.config = types.MultiStrategyConfig{ + FailureStrategy: types.FailureStrategyContinue, + Providers: map[string]types.ProviderConfig{ + "chain_context": {Type: "claims", Connection: map[string]interface{}{}}, + "ldap": {Type: "ldap", Connection: map[string]interface{}{ + "host": host, "port": port, "use_tls": false, + "bind_dn": "cn=admin,dc=opentdf,dc=test", "bind_password": "admin123", + }}, + }, + MappingStrategies: []types.MappingStrategy{ + environmentContractStrategy("chain_context"), + { + Name: "ldap_subject", + Provider: "ldap", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{JWTClaims: []types.JWTClaimCondition{{Claim: "sub", Operator: "exists"}}}, + InputMapping: []types.InputMapping{{ + JWTClaim: "sub", Parameter: "username", Required: true, + }}, + LDAPSearch: &types.LDAPSearchConfig{ + BaseDN: "ou=users,dc=opentdf,dc=test", + Filter: "(&(objectClass=inetOrgPerson)(uid={username}))", + Scope: "subtree", Attributes: []string{"uid", "mail", "departmentNumber", "businessCategory"}, + }, + OutputMapping: []types.OutputMapping{ + {SourceAttribute: "uid", ClaimName: "username"}, + {SourceAttribute: "mail", ClaimName: "email"}, + {SourceAttribute: "departmentNumber", ClaimName: "department"}, + {SourceAttribute: "businessCategory", ClaimName: "groups"}, + }, + }, + }, + } + return nil + } + adapter.teardown = func(ctx context.Context) error { + if container == nil { + return nil + } + return container.Terminate(ctx) + } + return adapter +} + +func environmentContractStrategy(provider string) types.MappingStrategy { + return types.MappingStrategy{ + Name: "client_environment", + Provider: provider, + EntityType: types.EntityTypeEnvironment, + Conditions: types.StrategyConditions{JWTClaims: []types.JWTClaimCondition{{Claim: "client_id", Operator: "exists"}}}, + OutputMapping: []types.OutputMapping{ + {SourceClaim: "client_id", ClaimName: "client_id"}, + }, + } +} + +func providerContractExpectations(provider, aliceEmail, aliceDepartment string, aliceGroups []interface{}, bobEmail, bobDepartment string, bobGroups []interface{}) []internal.ResolvedTokenChainExpectation { + return []internal.ResolvedTokenChainExpectation{ + providerContractExpectation(provider+"-alice", "alice", aliceEmail, aliceDepartment, aliceGroups), + providerContractExpectation(provider+"-bob", "bob", bobEmail, bobDepartment, bobGroups), + } +} + +func providerContractExpectation(tokenID, username, email, department string, groups []interface{}) internal.ResolvedTokenChainExpectation { + return internal.ResolvedTokenChainExpectation{ + Token: &entity.Token{EphemeralId: tokenID, Jwt: internal.CreateTestJWTWithClaims("opentdf-sdk", username, email, map[string]interface{}{ + "department": department, + "groups": groups, + })}, + Entities: []internal.ResolvedTokenChainEntityExpectation{ + { + ExpectedClaims: map[string]interface{}{"client_id": "opentdf-sdk"}, + Category: entity.Entity_CATEGORY_ENVIRONMENT, + }, + { + ExpectedClaims: map[string]interface{}{"username": username, "email": email, "department": department, "groups": groups}, + Category: entity.Entity_CATEGORY_SUBJECT, + }, + }, + } +} + +var _ internal.ResolvedTokenChainAdapter = (*multiStrategyProviderContractAdapter)(nil) diff --git a/service/entityresolution/multi-strategy/README.md b/service/entityresolution/multi-strategy/README.md index 4595d333bd..3626bb1454 100644 --- a/service/entityresolution/multi-strategy/README.md +++ b/service/entityresolution/multi-strategy/README.md @@ -487,6 +487,34 @@ services: - **Immediate failures**: Verify failure_strategy is set to "continue" for failover - **Backup never used**: Ensure backup strategies have identical conditions to primary +### Currently Unsupported Failure Policies + +Multi-Strategy ERS does not currently support requiring every selected strategy, a +specific strategy, or a named provider to participate successfully in the resolved +entity chain. + +In particular, there is no current equivalent of: + +```yaml +failure_strategy: require-all +required_strategies: + - corporate_ldap +``` + +With `continue`, any strategy execution failure—including a provider outage—may fall +through to later matching strategies. With `fail-fast`, the first strategy execution +failure aborts resolution. Failure causes may be distinguished for logging, audit, +and future policy, but they do not override the configured resolution behavior. + +An empty Claims-provider context is a successful empty context, not an unavailable +entity. It is passed to subject mapping evaluation and normally results in no matching +entitlements and therefore a deny decision. + +Deployments that must prove an authoritative source was available and contributed +context cannot express that requirement today. Supporting that guarantee requires a +new failure strategy (for example, `require-all`) or explicit required-strategy/provider +configuration, with corresponding chain and audit semantics. + ## Configuration Reference ### Provider Types diff --git a/service/entityresolution/multi-strategy/registration_test.go b/service/entityresolution/multi-strategy/registration_test.go index 5e1eebf8c0..90d6977021 100644 --- a/service/entityresolution/multi-strategy/registration_test.go +++ b/service/entityresolution/multi-strategy/registration_test.go @@ -8,13 +8,12 @@ import ( "github.com/opentdf/platform/protocol/go/entityresolution" "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" "github.com/opentdf/platform/service/logger" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" ) func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { - t.Helper() - erService, err := NewERS(t.Context(), types.MultiStrategyConfig{ Providers: map[string]types.ProviderConfig{ "jwt": { @@ -48,22 +47,16 @@ func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { }, }, }, logger.CreateTestLogger()) - if err != nil { - t.Fatalf("NewERS() error = %v", err) - } + require.NoError(t, err) claimsStruct, err := structpb.NewStruct(map[string]interface{}{ "sub": "diana", "email": "diana@example.com", }) - if err != nil { - t.Fatalf("structpb.NewStruct() error = %v", err) - } + require.NoError(t, err) claimsAny, err := anypb.New(claimsStruct) - if err != nil { - t.Fatalf("anypb.New() error = %v", err) - } + require.NoError(t, err) resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&entityresolution.ResolveEntitiesRequest{ Entities: []*authorization.Entity{ @@ -73,37 +66,20 @@ func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { }, }, })) - if err != nil { - t.Fatalf("ResolveEntities() error = %v", err) - } - - if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { - t.Fatalf("expected 1 entity representation, got %d", got) - } + require.NoError(t, err) + require.Len(t, resp.Msg.GetEntityRepresentations(), 1) props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() - if len(props) != 1 { - t.Fatalf("expected 1 additional props entry, got %d", len(props)) - } + require.Len(t, props, 1) result := props[0].AsMap() - if got := result["subject"]; got != "diana" { - t.Fatalf("expected subject diana, got %v", got) - } - if got := result["email_address"]; got != "diana@example.com" { - t.Fatalf("expected email_address diana@example.com, got %v", got) - } - if got := result["metadata_source"]; got != "jwt_claims" { - t.Fatalf("expected metadata_source jwt_claims, got %v", got) - } - if _, hasError := result["error"]; hasError { - t.Fatalf("expected successful resolution, got error payload: %v", result["error"]) - } + require.Equal(t, "diana", result["subject"]) + require.Equal(t, "diana@example.com", result["email_address"]) + require.Equal(t, "jwt_claims", result["metadata_source"]) + require.NotContains(t, result, "error") } func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { - t.Helper() - erService, err := NewERS(t.Context(), types.MultiStrategyConfig{ Providers: map[string]types.ProviderConfig{ "jwt": { @@ -124,9 +100,7 @@ func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { }, }, }, logger.CreateTestLogger()) - if err != nil { - t.Fatalf("NewERS() error = %v", err) - } + require.NoError(t, err) resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&entityresolution.ResolveEntitiesRequest{ Entities: []*authorization.Entity{{ @@ -134,24 +108,13 @@ func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { EntityType: &authorization.Entity_UserName{UserName: "alice"}, }}, })) - if err != nil { - t.Fatalf("ResolveEntities() error = %v", err) - } - - if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { - t.Fatalf("expected 1 entity representation, got %d", got) - } + require.NoError(t, err) + require.Len(t, resp.Msg.GetEntityRepresentations(), 1) props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() - if len(props) != 1 { - t.Fatalf("expected 1 additional props entry, got %d", len(props)) - } + require.Len(t, props, 1) result := props[0].AsMap() - if _, hasError := result["error"]; !hasError { - t.Fatalf("expected claims provider to fail without middleware claims for user_name entity, got %v", result) - } - if got := result["entity_id"]; got != "alice-user-name" { - t.Fatalf("expected entity_id alice-user-name, got %v", got) - } + require.Contains(t, result, "error") + require.Equal(t, "alice-user-name", result["entity_id"]) } diff --git a/service/entityresolution/multi-strategy/v2/registration_test.go b/service/entityresolution/multi-strategy/v2/registration_test.go index 7a77944ba8..8c8ef17ab4 100644 --- a/service/entityresolution/multi-strategy/v2/registration_test.go +++ b/service/entityresolution/multi-strategy/v2/registration_test.go @@ -112,21 +112,15 @@ func TestERSV2_ResolveEntities_PopulatesRepresentations(t *testing.T) { } ers, err := NewERSV2(t.Context(), config, logger.CreateTestLogger()) - if err != nil { - t.Fatalf("Failed to create ERSV2: %v", err) - } + require.NoError(t, err) claimsStruct, err := structpb.NewStruct(map[string]interface{}{ "sub": "alice", "email": "alice@example.com", }) - if err != nil { - t.Fatalf("Failed to build claims struct: %v", err) - } + require.NoError(t, err) claimsAny, err := anypb.New(claimsStruct) - if err != nil { - t.Fatalf("Failed to wrap claims in anypb.Any: %v", err) - } + require.NoError(t, err) req := connect.NewRequest(&ersV2.ResolveEntitiesRequest{ Entities: []*entity.Entity{ @@ -138,52 +132,32 @@ func TestERSV2_ResolveEntities_PopulatesRepresentations(t *testing.T) { }) resp, err := ers.ResolveEntities(t.Context(), req) - if err != nil { - t.Fatalf("ResolveEntities returned error: %v", err) - } + require.NoError(t, err) reps := resp.Msg.GetEntityRepresentations() - if len(reps) != 1 { - t.Fatalf("EntityRepresentations length = %d, want 1 (empty response means the handler silently dropped the entity via structpb.NewStruct failure)", len(reps)) - } - if got := reps[0].GetOriginalId(); got != "entity-1" { - t.Errorf("OriginalId = %q, want %q", got, "entity-1") - } + require.Len(t, reps, 1, "empty response means the handler silently dropped the entity via structpb.NewStruct failure") + require.Equal(t, "entity-1", reps[0].GetOriginalId()) props := reps[0].GetAdditionalProps() - if len(props) != 1 { - t.Fatalf("AdditionalProps length = %d, want 1", len(props)) - } + require.Len(t, props, 1) fields := props[0].GetFields() // The resolved claim should be present. - if got := fields["username"].GetStringValue(); got != "alice" { - t.Errorf("username in AdditionalProps = %q, want %q", got, "alice") - } + require.Equal(t, "alice", fields["username"].GetStringValue()) // metadata_attempted_strategies MUST serialize to a ListValue. If the // source-level fix regresses and the field is stored as []string again, // structpb.NewStruct will drop the whole entity and this assertion (and // the length assertion above) will fail. metaAttempted, ok := fields["metadata_attempted_strategies"] - if !ok { - t.Fatalf("metadata_attempted_strategies missing from AdditionalProps; the handler likely dropped the entity") - } + require.True(t, ok, "metadata_attempted_strategies missing from AdditionalProps; the handler likely dropped the entity") list := metaAttempted.GetListValue() - if list == nil { - t.Fatalf("metadata_attempted_strategies must be a ListValue, got kind %T", metaAttempted.GetKind()) - } - if got, want := len(list.GetValues()), 1; got != want { - t.Errorf("metadata_attempted_strategies length = %d, want %d", got, want) - } - if got := list.GetValues()[0].GetStringValue(); got != "jwt_strategy" { - t.Errorf("metadata_attempted_strategies[0] = %q, want %q", got, "jwt_strategy") - } + require.NotNil(t, list, "metadata_attempted_strategies has kind %T", metaAttempted.GetKind()) + require.Len(t, list.GetValues(), 1) + require.Equal(t, "jwt_strategy", list.GetValues()[0].GetStringValue()) } func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { - t.Helper() - erService, err := NewERSV2(t.Context(), types.MultiStrategyConfig{ Providers: map[string]types.ProviderConfig{ "jwt": { @@ -217,22 +191,16 @@ func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { }, }, }, logger.CreateTestLogger()) - if err != nil { - t.Fatalf("NewERSV2() error = %v", err) - } + require.NoError(t, err) claimsStruct, err := structpb.NewStruct(map[string]interface{}{ "sub": "diana", "email": "diana@example.com", }) - if err != nil { - t.Fatalf("structpb.NewStruct() error = %v", err) - } + require.NoError(t, err) claimsAny, err := anypb.New(claimsStruct) - if err != nil { - t.Fatalf("anypb.New() error = %v", err) - } + require.NoError(t, err) resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&ersV2.ResolveEntitiesRequest{ Entities: []*entity.Entity{ @@ -243,37 +211,20 @@ func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { }, }, })) - if err != nil { - t.Fatalf("ResolveEntities() error = %v", err) - } - - if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { - t.Fatalf("expected 1 entity representation, got %d", got) - } + require.NoError(t, err) + require.Len(t, resp.Msg.GetEntityRepresentations(), 1) props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() - if len(props) != 1 { - t.Fatalf("expected 1 additional props entry, got %d", len(props)) - } + require.Len(t, props, 1) result := props[0].AsMap() - if got := result["subject"]; got != "diana" { - t.Fatalf("expected subject diana, got %v", got) - } - if got := result["email_address"]; got != "diana@example.com" { - t.Fatalf("expected email_address diana@example.com, got %v", got) - } - if got := result["metadata_source"]; got != "jwt_claims" { - t.Fatalf("expected metadata_source jwt_claims, got %v", got) - } - if _, hasError := result["error"]; hasError { - t.Fatalf("expected successful resolution, got error payload: %v", result["error"]) - } + require.Equal(t, "diana", result["subject"]) + require.Equal(t, "diana@example.com", result["email_address"]) + require.Equal(t, "jwt_claims", result["metadata_source"]) + require.NotContains(t, result, "error") } func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { - t.Helper() - erService, err := NewERSV2(t.Context(), types.MultiStrategyConfig{ Providers: map[string]types.ProviderConfig{ "jwt": { @@ -294,9 +245,7 @@ func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { }, }, }, logger.CreateTestLogger()) - if err != nil { - t.Fatalf("NewERSV2() error = %v", err) - } + require.NoError(t, err) resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&ersV2.ResolveEntitiesRequest{ Entities: []*entity.Entity{{ @@ -305,26 +254,15 @@ func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { Category: entity.Entity_CATEGORY_SUBJECT, }}, })) - if err != nil { - t.Fatalf("ResolveEntities() error = %v", err) - } - - if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { - t.Fatalf("expected 1 entity representation, got %d", got) - } + require.NoError(t, err) + require.Len(t, resp.Msg.GetEntityRepresentations(), 1) props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() - if len(props) != 1 { - t.Fatalf("expected 1 additional props entry, got %d", len(props)) - } + require.Len(t, props, 1) result := props[0].AsMap() - if _, hasError := result["error"]; !hasError { - t.Fatalf("expected claims provider to fail without middleware claims for user_name entity, got %v", result) - } - if got := result["entity_id"]; got != "alice-user-name" { - t.Fatalf("expected entity_id alice-user-name, got %v", got) - } + require.Contains(t, result, "error") + require.Equal(t, "alice-user-name", result["entity_id"]) } func TestCreateEntityFromResultV2ExcludesResolutionMetadataFromPolicyClaims(t *testing.T) { diff --git a/service/internal/access/v2/just_in_time_pdp_test.go b/service/internal/access/v2/just_in_time_pdp_test.go index b218dc5256..d2bb16a937 100644 --- a/service/internal/access/v2/just_in_time_pdp_test.go +++ b/service/internal/access/v2/just_in_time_pdp_test.go @@ -2,7 +2,6 @@ package access import ( "context" - "errors" "testing" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -15,85 +14,46 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -type typedChainERSClient struct { - createCalls int - resolveCalls int - createReq *entityresolutionV2.CreateEntityChainsFromTokensRequest - resolveReq *entityresolutionV2.ResolveEntitiesRequest +type recordingERSV2Client struct { + createResponse *entityresolutionV2.CreateEntityChainsFromTokensResponse + resolveResponse *entityresolutionV2.ResolveEntitiesResponse + createCalls int + resolveCalls int + createReq *entityresolutionV2.CreateEntityChainsFromTokensRequest + resolveReq *entityresolutionV2.ResolveEntitiesRequest } -func (c *typedChainERSClient) CreateEntityChainsFromTokens(_ context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { +func (c *recordingERSV2Client) CreateEntityChainsFromTokens(_ context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { c.createCalls++ c.createReq = req - return &entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: []*entity.EntityChain{{ - Entities: []*entity.Entity{ - { - EphemeralId: "typed-user", - EntityType: &entity.Entity_UserName{UserName: "alice"}, - Category: entity.Entity_CATEGORY_SUBJECT, - }, - { - EphemeralId: "typed-env", - EntityType: &entity.Entity_ClientId{ClientId: "client-1"}, - Category: entity.Entity_CATEGORY_ENVIRONMENT, - }, - }, - }}}, nil + return c.createResponse, nil } -func (c *typedChainERSClient) ResolveEntities(_ context.Context, req *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { +func (c *recordingERSV2Client) ResolveEntities(_ context.Context, req *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { c.resolveCalls++ c.resolveReq = req - return &entityresolutionV2.ResolveEntitiesResponse{EntityRepresentations: []*entityresolutionV2.EntityRepresentation{{OriginalId: "typed-user"}}}, nil + return c.resolveResponse, nil } -type claimsChainERSClient struct { - createCalls int - resolveCalls int - claims *anypb.Any - createReq *entityresolutionV2.CreateEntityChainsFromTokensRequest -} - -func (c *claimsChainERSClient) CreateEntityChainsFromTokens(_ context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { - c.createCalls++ - c.createReq = req - return &entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: []*entity.EntityChain{{ - Entities: []*entity.Entity{ +func TestResolveEntitiesFromTokenDoesNotRehydrateResolvedChain(t *testing.T) { + claimsAny := claimsAnyForTest(t, map[string]interface{}{"username": "alice", "department": "engineering"}) + client := &recordingERSV2Client{createResponse: &entityresolutionV2.CreateEntityChainsFromTokensResponse{ + EntityChains: []*entity.EntityChain{{Entities: []*entity.Entity{ { - EphemeralId: "claims-user", - EntityType: &entity.Entity_Claims{Claims: c.claims}, + EphemeralId: "resolved-alice", + EntityType: &entity.Entity_Claims{Claims: claimsAny}, Category: entity.Entity_CATEGORY_SUBJECT, }, { - EphemeralId: "claims-env", - EntityType: &entity.Entity_Claims{Claims: c.claims}, + EphemeralId: "resolved-client", + EntityType: &entity.Entity_Claims{Claims: claimsAny}, Category: entity.Entity_CATEGORY_ENVIRONMENT, }, - }, - }}}, nil -} - -func (c *claimsChainERSClient) ResolveEntities(_ context.Context, _ *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { - c.resolveCalls++ - return nil, errors.New("unexpected ResolveEntities call") -} - -func TestResolveEntitiesFromTokenUsesResolvedClaimsWithoutHydration(t *testing.T) { - claimsStruct, err := structpb.NewStruct(map[string]interface{}{ - "username": "alice", - "department": "engineering", - }) - require.NoError(t, err) - claimsAny, err := anypb.New(claimsStruct) - require.NoError(t, err) - - client := &claimsChainERSClient{claims: claimsAny} - pdp := &JustInTimePDP{ - logger: logger.CreateTestLogger(), - sdk: &otdfSDK.SDK{EntityResolutionV2: client}, - } + }}}, + }} + pdp := testJITPDP(client) resources := []*authzV2.Resource{{EphemeralId: "resource-1"}} - token := &entity.Token{EphemeralId: "token", Jwt: "token"} + token := &entity.Token{EphemeralId: "alice-token", Jwt: "token"} reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, true, resources) require.NoError(t, err) @@ -112,11 +72,26 @@ func TestResolveEntitiesFromTokenUsesResolvedClaimsWithoutHydration(t *testing.T } func TestResolveEntitiesFromTokenFallsBackToHydrationForTypedChain(t *testing.T) { - client := &typedChainERSClient{} - pdp := &JustInTimePDP{ - logger: logger.CreateTestLogger(), - sdk: &otdfSDK.SDK{EntityResolutionV2: client}, + client := &recordingERSV2Client{ + createResponse: &entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: []*entity.EntityChain{{ + Entities: []*entity.Entity{ + { + EphemeralId: "typed-user", + EntityType: &entity.Entity_UserName{UserName: "alice"}, + Category: entity.Entity_CATEGORY_SUBJECT, + }, + { + EphemeralId: "typed-env", + EntityType: &entity.Entity_ClientId{ClientId: "client-1"}, + Category: entity.Entity_CATEGORY_ENVIRONMENT, + }, + }, + }}}, + resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{{OriginalId: "typed-user"}}, + }, } + pdp := testJITPDP(client) resources := []*authzV2.Resource{{EphemeralId: "resource-1"}} token := &entity.Token{EphemeralId: "token", Jwt: "token"} @@ -138,20 +113,28 @@ func TestResolveEntitiesFromTokenFallsBackToHydrationForTypedChain(t *testing.T) require.IsType(t, &entity.Entity_UserName{}, client.resolveReq.GetEntities()[0].GetEntityType()) } -func TestEntityRepresentationsFromResolvedChain(t *testing.T) { - claimsStruct, err := structpb.NewStruct(map[string]interface{}{ - "username": "alice", - "department": "engineering", - }) - if err != nil { - t.Fatalf("structpb.NewStruct() error = %v", err) - } - - claimsAny, err := anypb.New(claimsStruct) - if err != nil { - t.Fatalf("anypb.New() error = %v", err) - } +func TestResolveEntitiesFromEntityChainStillUsesERS(t *testing.T) { + client := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{{OriginalId: "alice"}}, + }} + pdp := testJITPDP(client) + chain := &entity.EntityChain{Entities: []*entity.Entity{{ + EphemeralId: "alice", + EntityType: &entity.Entity_UserName{UserName: "alice"}, + Category: entity.Entity_CATEGORY_SUBJECT, + }}} + + _, err := pdp.resolveEntitiesFromEntityChain(t.Context(), chain, true) + require.NoError(t, err) + require.Equal(t, 1, client.resolveCalls) + require.Zero(t, client.createCalls) + require.NotNil(t, client.resolveReq) + require.Len(t, client.resolveReq.GetEntities(), 1) + require.Equal(t, "alice", client.resolveReq.GetEntities()[0].GetEphemeralId()) +} +func TestEntityRepresentationsFromResolvedChain(t *testing.T) { + claimsAny := claimsAnyForTest(t, map[string]interface{}{"username": "alice", "department": "engineering"}) chain := &entity.EntityChain{ EphemeralId: "token-alice", Entities: []*entity.Entity{ @@ -169,39 +152,37 @@ func TestEntityRepresentationsFromResolvedChain(t *testing.T) { } reps, err := entityRepresentationsFromResolvedChain(chain, true) - if err != nil { - t.Fatalf("entityRepresentationsFromResolvedChain() error = %v", err) - } - - if got := len(reps); got != 1 { - t.Fatalf("expected 1 subject representation after skipping environment entities, got %d", got) - } - - props := reps[0].GetAdditionalProps() - if len(props) != 1 { - t.Fatalf("expected 1 additional props entry, got %d", len(props)) - } + require.NoError(t, err) + require.Len(t, reps, 1) - asMap := props[0].AsMap() - if got := asMap["username"]; got != "alice" { - t.Fatalf("expected username alice, got %v", got) - } - if got := asMap["department"]; got != "engineering" { - t.Fatalf("expected department engineering, got %v", got) - } + asMap := reps[0].GetAdditionalProps()[0].AsMap() + require.Equal(t, "alice", asMap["username"]) + require.Equal(t, "engineering", asMap["department"]) } func TestEntityRepresentationsFromResolvedChainRejectsTypedEntity(t *testing.T) { - chain := &entity.EntityChain{ - Entities: []*entity.Entity{{ - EphemeralId: "typed-user", - EntityType: &entity.Entity_UserName{UserName: "alice"}, - Category: entity.Entity_CATEGORY_SUBJECT, - }}, - } + chain := &entity.EntityChain{Entities: []*entity.Entity{{ + EphemeralId: "typed-user", + EntityType: &entity.Entity_UserName{UserName: "alice"}, + Category: entity.Entity_CATEGORY_SUBJECT, + }}} _, err := entityRepresentationsFromResolvedChain(chain, false) - if err == nil { - t.Fatal("expected typed token-chain entity to be rejected") + require.Error(t, err) +} + +func testJITPDP(client *recordingERSV2Client) *JustInTimePDP { + return &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{EntityResolutionV2: client}, } } + +func claimsAnyForTest(t *testing.T, claims map[string]interface{}) *anypb.Any { + t.Helper() + claimsStruct, err := structpb.NewStruct(claims) + require.NoError(t, err) + claimsAny, err := anypb.New(claimsStruct) + require.NoError(t, err) + return claimsAny +} diff --git a/service/pkg/protohelper/structpb.go b/service/pkg/protohelper/structpb.go index e49cfc9561..a44993b86d 100644 --- a/service/pkg/protohelper/structpb.go +++ b/service/pkg/protohelper/structpb.go @@ -1,16 +1,18 @@ package protohelper // StructPBCompatibleValue normalizes Go values into shapes accepted by structpb.NewStruct. -// In particular, it recursively converts []string into []interface{} while preserving +// In particular, it recursively converts typed slices into []interface{} while preserving // nested []interface{} and map[string]interface{} values. func StructPBCompatibleValue(value interface{}) interface{} { switch v := value.(type) { case []string: - result := make([]interface{}, len(v)) - for i, item := range v { - result[i] = item - } - return result + return structPBSlice(v) + case []float64: + return structPBSlice(v) + case []bool: + return structPBSlice(v) + case []int: + return structPBSlice(v) case []interface{}: result := make([]interface{}, len(v)) for i, item := range v { @@ -27,3 +29,11 @@ func StructPBCompatibleValue(value interface{}) interface{} { return value } } + +func structPBSlice[T any](values []T) []interface{} { + result := make([]interface{}, len(values)) + for i, value := range values { + result[i] = StructPBCompatibleValue(value) + } + return result +} diff --git a/service/pkg/protohelper/structpb_test.go b/service/pkg/protohelper/structpb_test.go index ed7244ed35..129ccb3f09 100644 --- a/service/pkg/protohelper/structpb_test.go +++ b/service/pkg/protohelper/structpb_test.go @@ -1,37 +1,38 @@ package protohelper import ( - "reflect" "testing" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) func TestStructPBCompatibleValue(t *testing.T) { input := map[string]interface{}{ "attempted_strategies": []string{"claims", "ldap"}, + "scores": []float64{1.5, 2.5}, + "flags": []bool{true, false}, + "identifiers": []int{1, 2}, "nested": map[string]interface{}{ "values": []interface{}{ "ok", []string{"a", "b"}, - map[string]interface{}{"inner": []string{"x", "y"}}, + map[string]interface{}{ + "inner": []bool{true, false}, + }, }, }, } normalized := StructPBCompatibleValue(input) - normalizedMap, ok := normalized.(map[string]interface{}) - if !ok { - t.Fatalf("expected normalized result to be map[string]interface{}, got %T", normalized) - } + require.True(t, ok, "normalized result has type %T", normalized) - expectedStrategies := []interface{}{"claims", "ldap"} - if !reflect.DeepEqual(normalizedMap["attempted_strategies"], expectedStrategies) { - t.Fatalf("expected attempted_strategies %v, got %v", expectedStrategies, normalizedMap["attempted_strategies"]) - } + require.Equal(t, []interface{}{"claims", "ldap"}, normalizedMap["attempted_strategies"]) + require.Equal(t, []interface{}{1.5, 2.5}, normalizedMap["scores"]) + require.Equal(t, []interface{}{true, false}, normalizedMap["flags"]) + require.Equal(t, []interface{}{1, 2}, normalizedMap["identifiers"]) - if _, err := structpb.NewStruct(normalizedMap); err != nil { - t.Fatalf("expected normalized map to be structpb-compatible, got error: %v", err) - } + _, err := structpb.NewStruct(normalizedMap) + require.NoError(t, err) } diff --git a/tests-bdd/cukes/steps_authorization_test.go b/tests-bdd/cukes/steps_authorization_test.go index 1aae935565..7a06fa4750 100644 --- a/tests-bdd/cukes/steps_authorization_test.go +++ b/tests-bdd/cukes/steps_authorization_test.go @@ -3,25 +3,18 @@ package cukes import ( "testing" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) func TestConvertInterfaceToAny_PlainClaimsJSON(t *testing.T) { anyMsg, err := ConvertInterfaceToAny([]byte(`{"userName":"diana","department":"engineering"}`)) - if err != nil { - t.Fatalf("ConvertInterfaceToAny() error = %v", err) - } + require.NoError(t, err) var claimsStruct structpb.Struct - if err := anyMsg.UnmarshalTo(&claimsStruct); err != nil { - t.Fatalf("UnmarshalTo(structpb.Struct) error = %v", err) - } + require.NoError(t, anyMsg.UnmarshalTo(&claimsStruct)) claims := claimsStruct.AsMap() - if got := claims["userName"]; got != "diana" { - t.Fatalf("expected userName diana, got %v", got) - } - if got := claims["department"]; got != "engineering" { - t.Fatalf("expected department engineering, got %v", got) - } + require.Equal(t, "diana", claims["userName"]) + require.Equal(t, "engineering", claims["department"]) } diff --git a/tests-bdd/cukes/steps_ldap.go b/tests-bdd/cukes/steps_ldap.go index b1e92fb6b4..31e92172e9 100644 --- a/tests-bdd/cukes/steps_ldap.go +++ b/tests-bdd/cukes/steps_ldap.go @@ -65,7 +65,18 @@ func (s *LDAPStepDefinitions) anLDAPDirectoryWithTestUsers(ctx context.Context) FileMode: ldapFileMode, }, }, - WaitingFor: wait.ForLog("slapd starting").WithStartupTimeout(ldapStartupTimeout), + // The pinned osixia image includes ldapsearch. Verify the seeded entry is + // queryable instead of treating a listening port or startup log as ready. + WaitingFor: wait.ForAll( + wait.ForListeningPort("389/tcp"), + wait.ForExec([]string{ + "sh", "-c", + "ldapsearch -LLL -x -H ldap://localhost:389 " + + "-D cn=admin,dc=opentdf,dc=test -w admin123 " + + "-b ou=users,dc=opentdf,dc=test '(uid=alice)' dn " + + "| grep -q '^dn: uid=alice,ou=users,dc=opentdf,dc=test$'", + }).WithPollInterval(time.Second), + ).WithDeadline(ldapStartupTimeout), } ldapContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ diff --git a/tests-bdd/go.mod b/tests-bdd/go.mod index 10cb2750f2..b0e2647e1d 100644 --- a/tests-bdd/go.mod +++ b/tests-bdd/go.mod @@ -14,6 +14,7 @@ require ( github.com/opentdf/platform/sdk v0.25.0 github.com/opentdf/platform/service v0.7.2 github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/compose v0.42.0 golang.org/x/oauth2 v0.36.0 @@ -197,7 +198,6 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect