From 6f5975b687f9bf50dd04f0445a79644e7b2f90a9 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Fri, 4 Sep 2026 11:34:47 -0400 Subject: [PATCH 1/3] test(ers): cover environment-first strategy ordering in token chains First-match-wins chain building stops at the first matching strategy. When that strategy is entity_type: environment, the chain holds only an ENVIRONMENT entity. GetDecision resolves tokens with skipEnvironmentEntities=true, so the chain filters to empty and the request fails with "no subject entities to resolve"; the error is not errResolvedTokenChainRequiresHydration, so the hydration fallback does not fire. Adds tests pinning that behavior end to end through the real ERS v2 handler, plus a control proving the same token resolves when a subject strategy is first. Also asserts that opentdf-ers-test.yaml -- the config the README tells operators to start the platform with -- selects an environment strategy first for a Keycloak-shaped token, since its client_environment_sql strategy is conditioned on "azp exists" and listed ahead of every subject strategy. Tests only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Paul Flynn --- .../multi-strategy/shipped_config_test.go | 94 ++++++++++++ ...just_in_time_pdp_environment_chain_test.go | 135 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 service/entityresolution/multi-strategy/shipped_config_test.go create mode 100644 service/internal/access/v2/just_in_time_pdp_environment_chain_test.go diff --git a/service/entityresolution/multi-strategy/shipped_config_test.go b/service/entityresolution/multi-strategy/shipped_config_test.go new file mode 100644 index 0000000000..2ae3752a72 --- /dev/null +++ b/service/entityresolution/multi-strategy/shipped_config_test.go @@ -0,0 +1,94 @@ +package multistrategy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-viper/mapstructure/v2" + "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// shippedERSConfig loads the multi-strategy block out of opentdf-ers-test.yaml, the +// repo-root config the README tells operators to start the platform with +// (`go run ./service start --config opentdf-ers-test.yaml`). +func shippedERSConfig(t *testing.T) types.MultiStrategyConfig { + t.Helper() + + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "opentdf-ers-test.yaml")) + require.NoError(t, err) + + var root struct { + EntityResolution struct { + Type string `yaml:"type"` + Config map[string]interface{} `yaml:"config"` + } `yaml:"entityresolution"` + } + require.NoError(t, yaml.Unmarshal(raw, &root)) + require.Equal(t, "multi-strategy", root.EntityResolution.Type) + require.NotEmpty(t, root.EntityResolution.Config, "entityresolution.config block not found") + + var config types.MultiStrategyConfig + require.NoError(t, mapstructure.Decode(root.EntityResolution.Config, &config)) + require.NotEmpty(t, config.MappingStrategies) + return config +} + +// keycloakStyleClaims mirrors the claims on a Keycloak access token. Every Keycloak token +// carries "azp" (authorized party), whether it came from a user login or client credentials. +func keycloakStyleClaims() types.JWTClaims { + return types.JWTClaims{ + "sub": "37c8ec42-ec2d-4b3b-8c2d-3c8b6c1c2f11", + "azp": "opentdf-sdk", + "preferred_username": "alice", + "email": "alice@example.com", + } +} + +// TestShippedERSConfigSelectsEnvironmentStrategyFirst shows the environment-first ordering is +// not hypothetical: the config this repo ships and documents lists an entity_type: environment +// strategy conditioned on "azp exists" ahead of every subject strategy, and "azp" is present on +// every Keycloak token. Combined with first-match-wins chain building, the chain such a token +// produces holds only an ENVIRONMENT entity. +func TestShippedERSConfigSelectsEnvironmentStrategyFirst(t *testing.T) { + config := shippedERSConfig(t) + require.Equal(t, types.FailureStrategyContinue, config.FailureStrategy) + + matcher := NewStrategyMatcher(config.MappingStrategies) + matched, err := matcher.SelectStrategies(t.Context(), keycloakStyleClaims()) + require.NoError(t, err) + require.NotEmpty(t, matched) + + require.Equal(t, "client_environment_sql", matched[0].Name) + require.Equal(t, types.EntityTypeEnvironment, matched[0].EntityType, + "first matching strategy resolves an environment entity, so first-match-wins yields an environment-only chain") + + // Subject strategies do match this token; they are just never reached. + var subjectNames []string + for _, strategy := range matched[1:] { + if strategy.EntityType == types.EntityTypeSubject { + subjectNames = append(subjectNames, strategy.Name) + } + } + require.NotEmpty(t, subjectNames, "subject strategies match but are skipped after the first success") +} + +// TestShippedERSConfigHasNoOrderingGuard records that nothing in configuration handling +// prevents this: entity_type is read only when building the entity, never validated, and +// SelectStrategies preserves configuration order rather than preferring subject strategies. +func TestShippedERSConfigHasNoOrderingGuard(t *testing.T) { + config := shippedERSConfig(t) + + // Reordering the same strategies changes which entity the chain gets, so ordering is + // load-bearing configuration with no validation behind it. + reversed := make([]types.MappingStrategy, 0, len(config.MappingStrategies)) + for i := len(config.MappingStrategies) - 1; i >= 0; i-- { + reversed = append(reversed, config.MappingStrategies[i]) + } + + matched, err := NewStrategyMatcher(reversed).SelectStrategies(t.Context(), keycloakStyleClaims()) + require.NoError(t, err) + require.Equal(t, types.EntityTypeSubject, matched[0].EntityType) +} diff --git a/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go b/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go new file mode 100644 index 0000000000..809025f7c5 --- /dev/null +++ b/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go @@ -0,0 +1,135 @@ +package access + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + otdfSDK "github.com/opentdf/platform/sdk" + "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/stretchr/testify/require" +) + +// realERSV2Client adapts the in-process multi-strategy ERS v2 handler to the SDK client +// interface the PDP consumes, so these tests exercise the real chain-building code rather +// than a canned response. +type realERSV2Client struct { + ers *multistrategyV2.ERSV2 + resolveCalls int +} + +func (c *realERSV2Client) CreateEntityChainsFromTokens(ctx context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { + resp, err := c.ers.CreateEntityChainsFromTokens(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + +func (c *realERSV2Client) ResolveEntities(ctx context.Context, req *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { + c.resolveCalls++ + resp, err := c.ers.ResolveEntities(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + +// environmentFirstJWT carries both "azp" and "sub", so both strategies below match it. +// Payload: {"sub":"alice","azp":"opentdf-sdk","iat":1600000000,"exp":4102444800} +const environmentFirstJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiJhbGljZSIsImF6cCI6Im9wZW50ZGYtc2RrIiwiaWF0IjoxNjAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDB9." + + "dGVzdHNpZ25hdHVyZQ" + +func multiStrategyPDP(t *testing.T, strategies ...types.MappingStrategy) (*JustInTimePDP, *realERSV2Client) { + t.Helper() + + ers, err := multistrategyV2.NewERSV2(t.Context(), types.MultiStrategyConfig{ + FailureStrategy: types.FailureStrategyContinue, + Providers: map[string]types.ProviderConfig{ + "jwt": {Type: "claims", Connection: map[string]interface{}{}}, + }, + MappingStrategies: strategies, + }, logger.CreateTestLogger()) + require.NoError(t, err) + + client := &realERSV2Client{ers: ers} + return &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{EntityResolutionV2: client}, + }, client +} + +func environmentMappingStrategy() types.MappingStrategy { + return types.MappingStrategy{ + Name: "client_environment", + Provider: "jwt", + EntityType: types.EntityTypeEnvironment, + Conditions: types.StrategyConditions{ + JWTClaims: []types.JWTClaimCondition{{Claim: "azp", Operator: "exists"}}, + }, + OutputMapping: []types.OutputMapping{{SourceClaim: "azp", ClaimName: "client_id"}}, + } +} + +func subjectMappingStrategy() types.MappingStrategy { + return types.MappingStrategy{ + Name: "user_subject", + Provider: "jwt", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{ + JWTClaims: []types.JWTClaimCondition{{Claim: "sub", Operator: "exists"}}, + }, + OutputMapping: []types.OutputMapping{{SourceClaim: "sub", ClaimName: "username"}}, + } +} + +// TestResolveEntitiesFromTokenWithEnvironmentFirstStrategy documents the interaction between +// first-match-wins chain building and the decision flow's skipEnvironmentEntities=true: +// when the first matching strategy is entity_type: environment, the chain holds only an +// ENVIRONMENT entity, the PDP filters it out, and nothing is left to decide on. +func TestResolveEntitiesFromTokenWithEnvironmentFirstStrategy(t *testing.T) { + pdp, client := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) + token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} + + reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, true, nil) + + require.Error(t, err, "environment-only chain should not produce entity representations") + require.Nil(t, reps) + require.ErrorContains(t, err, "no subject entities to resolve - all were environment entities and skipped") + // The hydration fallback does not fire, so there is no second chance to reach the + // subject strategy: the whole decision request fails. + require.NotErrorIs(t, err, errResolvedTokenChainRequiresHydration) + require.Zero(t, client.resolveCalls, "no ERS re-resolution is attempted") +} + +// TestResolveEntitiesFromTokenWithSubjectFirstStrategy is the control: the same token and the +// same two strategies in the opposite order resolve normally. +func TestResolveEntitiesFromTokenWithSubjectFirstStrategy(t *testing.T) { + pdp, _ := multiStrategyPDP(t, subjectMappingStrategy(), environmentMappingStrategy()) + token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} + + reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, true, nil) + + require.NoError(t, err) + require.Len(t, reps, 1) + require.Equal(t, "alice", reps[0].GetAdditionalProps()[0].AsMap()["username"]) +} + +// TestResolveEntitiesFromTokenEnvironmentOnlyChainKeepsEnvironmentWhenNotSkipped isolates the +// cause: the chain itself is well-formed, and the same token resolves fine when environment +// entities are not filtered. Only the decision flow's skipEnvironmentEntities=true empties it. +func TestResolveEntitiesFromTokenEnvironmentOnlyChainKeepsEnvironmentWhenNotSkipped(t *testing.T) { + pdp, _ := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) + token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} + + reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, false, nil) + + require.NoError(t, err) + require.Len(t, reps, 1) + require.Equal(t, "opentdf-sdk", reps[0].GetAdditionalProps()[0].AsMap()["client_id"]) +} From 74b844ce89f70966f54362bda37ec7a1bf5849b9 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Fri, 4 Sep 2026 11:59:40 -0400 Subject: [PATCH 2/3] test(ers): assert desired behavior for environment-first strategy order Flips the two characterization tests to assert the outcome that should hold, so they fail and demonstrate the bug rather than pinning it. RED: - resolveEntitiesFromToken must resolve alice's subject entity when an entity_type: environment strategy is configured ahead of the subject strategy. Fails with "no subject entities to resolve - all were environment entities and skipped". - opentdf-ers-test.yaml must select a subject-resolving strategy for a Keycloak token. Fails: client_environment_sql (environment, "azp exists") wins ahead of user_subject_sql. Both assert the required outcome rather than a mechanism, so reordering the YAML, skipping environment-typed strategies when picking the chain winner, or rejecting the ordering at config load all satisfy them. Controls stay green: the same strategies in subject-first order resolve, and the environment-first chain is well-formed until the decision flow filters it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Paul Flynn --- .../multi-strategy/shipped_config_test.go | 59 ++++++++++-------- ...just_in_time_pdp_environment_chain_test.go | 62 ++++++++++++------- 2 files changed, 70 insertions(+), 51 deletions(-) diff --git a/service/entityresolution/multi-strategy/shipped_config_test.go b/service/entityresolution/multi-strategy/shipped_config_test.go index 2ae3752a72..5a2b9fe9e7 100644 --- a/service/entityresolution/multi-strategy/shipped_config_test.go +++ b/service/entityresolution/multi-strategy/shipped_config_test.go @@ -47,42 +47,38 @@ func keycloakStyleClaims() types.JWTClaims { } } -// TestShippedERSConfigSelectsEnvironmentStrategyFirst shows the environment-first ordering is -// not hypothetical: the config this repo ships and documents lists an entity_type: environment -// strategy conditioned on "azp exists" ahead of every subject strategy, and "azp" is present on -// every Keycloak token. Combined with first-match-wins chain building, the chain such a token -// produces holds only an ENVIRONMENT entity. -func TestShippedERSConfigSelectsEnvironmentStrategyFirst(t *testing.T) { +// TestShippedERSConfigResolvesASubjectForKeycloakToken is the second failing test, and it is +// what makes the bug operator-facing rather than theoretical. +// +// Under first-match-wins the strategy that wins is the first one whose conditions match, so a +// config is only usable for decisions if that winner resolves a subject entity. The config +// this repo ships and documents (README: `go run ./service start --config +// opentdf-ers-test.yaml`) fails that: client_environment_sql is entity_type: environment, +// conditioned on "azp exists", and listed ahead of every subject strategy. Every Keycloak +// token carries azp, so every token loses its subject entity. +// +// Fixable either by reordering the YAML or by making strategy order stop deciding the entity +// category; this test does not care which. +func TestShippedERSConfigResolvesASubjectForKeycloakToken(t *testing.T) { config := shippedERSConfig(t) require.Equal(t, types.FailureStrategyContinue, config.FailureStrategy) - matcher := NewStrategyMatcher(config.MappingStrategies) - matched, err := matcher.SelectStrategies(t.Context(), keycloakStyleClaims()) + matched, err := NewStrategyMatcher(config.MappingStrategies).SelectStrategies(t.Context(), keycloakStyleClaims()) require.NoError(t, err) require.NotEmpty(t, matched) - require.Equal(t, "client_environment_sql", matched[0].Name) - require.Equal(t, types.EntityTypeEnvironment, matched[0].EntityType, - "first matching strategy resolves an environment entity, so first-match-wins yields an environment-only chain") - - // Subject strategies do match this token; they are just never reached. - var subjectNames []string - for _, strategy := range matched[1:] { - if strategy.EntityType == types.EntityTypeSubject { - subjectNames = append(subjectNames, strategy.Name) - } - } - require.NotEmpty(t, subjectNames, "subject strategies match but are skipped after the first success") + require.Equal(t, types.EntityTypeSubject, matched[0].EntityType, + "winning strategy %q resolves an %s entity, so a Keycloak token produces a chain with no subject; matched order was %v", + matched[0].Name, matched[0].EntityType, strategyNames(matched)) } -// TestShippedERSConfigHasNoOrderingGuard records that nothing in configuration handling -// prevents this: entity_type is read only when building the entity, never validated, and -// SelectStrategies preserves configuration order rather than preferring subject strategies. -func TestShippedERSConfigHasNoOrderingGuard(t *testing.T) { +// TestShippedERSConfigOrderingIsUnvalidated is the supporting observation, and it passes: +// nothing rejects or normalizes the ordering. entity_type is never validated, and +// SelectStrategies preserves configuration order rather than preferring subject strategies, +// so simply reversing the same strategies changes which entity a token resolves to. +func TestShippedERSConfigOrderingIsUnvalidated(t *testing.T) { config := shippedERSConfig(t) - // Reordering the same strategies changes which entity the chain gets, so ordering is - // load-bearing configuration with no validation behind it. reversed := make([]types.MappingStrategy, 0, len(config.MappingStrategies)) for i := len(config.MappingStrategies) - 1; i >= 0; i-- { reversed = append(reversed, config.MappingStrategies[i]) @@ -90,5 +86,14 @@ func TestShippedERSConfigHasNoOrderingGuard(t *testing.T) { matched, err := NewStrategyMatcher(reversed).SelectStrategies(t.Context(), keycloakStyleClaims()) require.NoError(t, err) - require.Equal(t, types.EntityTypeSubject, matched[0].EntityType) + require.Equal(t, types.EntityTypeSubject, matched[0].EntityType, + "the same strategies in the opposite order win with a subject entity") +} + +func strategyNames(strategies []*types.MappingStrategy) []string { + names := make([]string, 0, len(strategies)) + for _, strategy := range strategies { + names = append(names, strategy.Name+"="+strategy.EntityType) + } + return names } diff --git a/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go b/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go index 809025f7c5..88b4debd48 100644 --- a/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go +++ b/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go @@ -88,28 +88,34 @@ func subjectMappingStrategy() types.MappingStrategy { } } -// TestResolveEntitiesFromTokenWithEnvironmentFirstStrategy documents the interaction between -// first-match-wins chain building and the decision flow's skipEnvironmentEntities=true: -// when the first matching strategy is entity_type: environment, the chain holds only an -// ENVIRONMENT entity, the PDP filters it out, and nothing is left to decide on. -func TestResolveEntitiesFromTokenWithEnvironmentFirstStrategy(t *testing.T) { - pdp, client := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) +// TestResolveEntitiesFromTokenResolvesSubjectWhenEnvironmentStrategyIsFirst is the failing +// test that describes the bug. +// +// A token that matches a subject strategy must yield a decision-usable entity +// representation. It does not when an entity_type: environment strategy is configured ahead +// of the subject strategy: first-match-wins chain building stops at the environment strategy, +// the decision flow filters environment entities out (skipEnvironmentEntities=true), and the +// request fails outright instead of deciding on alice. +// +// This test asserts the required outcome, not a mechanism, so any of the plausible fixes +// satisfies it: skipping environment-typed strategies when picking the chain winner, keeping +// the environment entity but continuing to the first subject match, or rejecting the ordering +// at config load. +func TestResolveEntitiesFromTokenResolvesSubjectWhenEnvironmentStrategyIsFirst(t *testing.T) { + pdp, _ := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, true, nil) - require.Error(t, err, "environment-only chain should not produce entity representations") - require.Nil(t, reps) - require.ErrorContains(t, err, "no subject entities to resolve - all were environment entities and skipped") - // The hydration fallback does not fire, so there is no second chance to reach the - // subject strategy: the whole decision request fails. - require.NotErrorIs(t, err, errResolvedTokenChainRequiresHydration) - require.Zero(t, client.resolveCalls, "no ERS re-resolution is attempted") + require.NoError(t, err, "a token matching a subject strategy must resolve regardless of strategy order") + require.Len(t, reps, 1) + require.Equal(t, "alice", reps[0].GetAdditionalProps()[0].AsMap()["username"]) } -// TestResolveEntitiesFromTokenWithSubjectFirstStrategy is the control: the same token and the -// same two strategies in the opposite order resolve normally. -func TestResolveEntitiesFromTokenWithSubjectFirstStrategy(t *testing.T) { +// TestResolveEntitiesFromTokenResolvesSubjectWhenSubjectStrategyIsFirst is the control. Same +// token, same two strategies, opposite order. It passes today, which is what makes strategy +// ordering the variable under test. +func TestResolveEntitiesFromTokenResolvesSubjectWhenSubjectStrategyIsFirst(t *testing.T) { pdp, _ := multiStrategyPDP(t, subjectMappingStrategy(), environmentMappingStrategy()) token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} @@ -120,16 +126,24 @@ func TestResolveEntitiesFromTokenWithSubjectFirstStrategy(t *testing.T) { require.Equal(t, "alice", reps[0].GetAdditionalProps()[0].AsMap()["username"]) } -// TestResolveEntitiesFromTokenEnvironmentOnlyChainKeepsEnvironmentWhenNotSkipped isolates the -// cause: the chain itself is well-formed, and the same token resolves fine when environment -// entities are not filtered. Only the decision flow's skipEnvironmentEntities=true empties it. -func TestResolveEntitiesFromTokenEnvironmentOnlyChainKeepsEnvironmentWhenNotSkipped(t *testing.T) { - pdp, _ := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) +// TestResolveEntitiesFromTokenEnvironmentFirstChainIsWellFormedButEmptyAfterFiltering pins the +// mechanism behind the failure above: the chain the ERS builds is valid, and the same token +// resolves when environment entities are not filtered. It is the filtering step in the +// decision flow that leaves nothing to decide on. +func TestResolveEntitiesFromTokenEnvironmentFirstChainIsWellFormedButEmptyAfterFiltering(t *testing.T) { + pdp, client := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, false, nil) - - require.NoError(t, err) + require.NoError(t, err, "the chain itself is well-formed") require.Len(t, reps, 1) - require.Equal(t, "opentdf-sdk", reps[0].GetAdditionalProps()[0].AsMap()["client_id"]) + require.Equal(t, "opentdf-sdk", reps[0].GetAdditionalProps()[0].AsMap()["client_id"], + "the chain holds only the environment entity, so the subject strategy never ran") + + // And nothing recovers it: the failure is not errResolvedTokenChainRequiresHydration, so + // resolveEntitiesFromToken's hydration fallback never re-resolves through ERS. + _, err = pdp.resolveEntitiesFromToken(t.Context(), token, true, nil) + require.ErrorContains(t, err, "no subject entities to resolve - all were environment entities and skipped") + require.NotErrorIs(t, err, errResolvedTokenChainRequiresHydration) + require.Zero(t, client.resolveCalls, "no ERS re-resolution is attempted") } From 05fc8eee6a3fbf0da6d045c96933cf1efb26673e Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Fri, 4 Sep 2026 12:12:28 -0400 Subject: [PATCH 3/3] test(ers): drive the PDP through the real Connect transport Replaces the hand-written realERSV2Client adapter, which was "real" only in that it delegated to the ERS implementation; the client half was still test scaffolding standing in for sdkconnect. The harness now mounts the generated entityresolutionv2connect.NewEntityResolutionServiceHandler over the real ERSV2 on an httptest server, and the PDP reaches it through sdkconnect.NewEntityResolutionServiceClientV2ConnectWrapper -- the same client sdk.New builds. Proto marshalling, the Connect codec, and the production client wrapper are all exercised; only the strategy configuration is test-supplied. Call-count assertions move to a connect.Interceptor keyed on the generated procedure constants, so they observe real RPCs instead of adapter bookkeeping. Same RED, same failure reason. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Paul Flynn --- ...just_in_time_pdp_environment_chain_test.go | 71 ++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go b/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go index 88b4debd48..684615bb66 100644 --- a/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go +++ b/service/internal/access/v2/just_in_time_pdp_environment_chain_test.go @@ -2,50 +2,47 @@ package access import ( "context" + "net/http" + "net/http/httptest" "testing" "connectrpc.com/connect" "github.com/opentdf/platform/protocol/go/entity" - entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/opentdf/platform/protocol/go/entityresolution/v2/entityresolutionv2connect" otdfSDK "github.com/opentdf/platform/sdk" + "github.com/opentdf/platform/sdk/sdkconnect" "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/stretchr/testify/require" ) -// realERSV2Client adapts the in-process multi-strategy ERS v2 handler to the SDK client -// interface the PDP consumes, so these tests exercise the real chain-building code rather -// than a canned response. -type realERSV2Client struct { - ers *multistrategyV2.ERSV2 - resolveCalls int -} - -func (c *realERSV2Client) CreateEntityChainsFromTokens(ctx context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { - resp, err := c.ers.CreateEntityChainsFromTokens(ctx, connect.NewRequest(req)) - if err != nil { - return nil, err - } - return resp.Msg, nil -} - -func (c *realERSV2Client) ResolveEntities(ctx context.Context, req *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { - c.resolveCalls++ - resp, err := c.ers.ResolveEntities(ctx, connect.NewRequest(req)) - if err != nil { - return nil, err - } - return resp.Msg, nil -} - // environmentFirstJWT carries both "azp" and "sub", so both strategies below match it. // Payload: {"sub":"alice","azp":"opentdf-sdk","iat":1600000000,"exp":4102444800} const environmentFirstJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiJhbGljZSIsImF6cCI6Im9wZW50ZGYtc2RrIiwiaWF0IjoxNjAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDB9." + "dGVzdHNpZ25hdHVyZQ" -func multiStrategyPDP(t *testing.T, strategies ...types.MappingStrategy) (*JustInTimePDP, *realERSV2Client) { +// procedureCounter tallies the RPCs the PDP actually issues, so tests can assert which +// procedures were reached without standing in for the client. +type procedureCounter struct { + calls map[string]int +} + +func (p *procedureCounter) interceptor() connect.Interceptor { + return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + p.calls[req.Spec().Procedure]++ + return next(ctx, req) + } + }) +} + +// multiStrategyPDP wires a JustInTimePDP to a multi-strategy ERS over the real transport: the +// generated Connect handler serves the real ERSV2 implementation, and the PDP reaches it +// through the same sdkconnect client wrapper the platform builds in sdk.New. Nothing here +// stands in for production code except the strategy configuration. +func multiStrategyPDP(t *testing.T, strategies ...types.MappingStrategy) (*JustInTimePDP, *procedureCounter) { t.Helper() ers, err := multistrategyV2.NewERSV2(t.Context(), types.MultiStrategyConfig{ @@ -57,11 +54,19 @@ func multiStrategyPDP(t *testing.T, strategies ...types.MappingStrategy) (*JustI }, logger.CreateTestLogger()) require.NoError(t, err) - client := &realERSV2Client{ers: ers} + counter := &procedureCounter{calls: make(map[string]int)} + mux := http.NewServeMux() + mux.Handle(entityresolutionv2connect.NewEntityResolutionServiceHandler(ers, connect.WithInterceptors(counter.interceptor()))) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return &JustInTimePDP{ logger: logger.CreateTestLogger(), - sdk: &otdfSDK.SDK{EntityResolutionV2: client}, - }, client + sdk: &otdfSDK.SDK{ + EntityResolutionV2: sdkconnect.NewEntityResolutionServiceClientV2ConnectWrapper(server.Client(), server.URL), + }, + }, counter } func environmentMappingStrategy() types.MappingStrategy { @@ -131,7 +136,7 @@ func TestResolveEntitiesFromTokenResolvesSubjectWhenSubjectStrategyIsFirst(t *te // resolves when environment entities are not filtered. It is the filtering step in the // decision flow that leaves nothing to decide on. func TestResolveEntitiesFromTokenEnvironmentFirstChainIsWellFormedButEmptyAfterFiltering(t *testing.T) { - pdp, client := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) + pdp, counter := multiStrategyPDP(t, environmentMappingStrategy(), subjectMappingStrategy()) token := &entity.Token{EphemeralId: "alice-token", Jwt: environmentFirstJWT} reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, false, nil) @@ -145,5 +150,7 @@ func TestResolveEntitiesFromTokenEnvironmentFirstChainIsWellFormedButEmptyAfterF _, err = pdp.resolveEntitiesFromToken(t.Context(), token, true, nil) require.ErrorContains(t, err, "no subject entities to resolve - all were environment entities and skipped") require.NotErrorIs(t, err, errResolvedTokenChainRequiresHydration) - require.Zero(t, client.resolveCalls, "no ERS re-resolution is attempted") + require.Zero(t, counter.calls[entityresolutionv2connect.EntityResolutionServiceResolveEntitiesProcedure], + "no ERS re-resolution is attempted") + require.Equal(t, 2, counter.calls[entityresolutionv2connect.EntityResolutionServiceCreateEntityChainsFromTokensProcedure]) }