Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion service/entityresolution/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading