From 4887d310a4932bf1fceb0c02c054803433310e77 Mon Sep 17 00:00:00 2001 From: Carlos Herrero Date: Mon, 16 Mar 2026 17:35:24 +0100 Subject: [PATCH] refactor: remove extractOverride from plugins/contrib resolver, add DataString to SDK also track go.work for CI cross-module builds --- .gitignore | 4 -- docs/reference/contrib-plugins.md | 10 ++-- docs/reference/sdk.md | 45 ++++++++++++++- docs/tutorials/microsoft-sam-mux.md | 1 + go.work | 7 +++ plugins/contrib/errors.go | 6 -- plugins/contrib/microsoft/token.go | 10 +++- plugins/contrib/microsoft/token_test.go | 18 +++--- plugins/contrib/resolver.go | 29 +++------- plugins/contrib/resolver_test.go | 70 ++++++---------------- sdk/context.go | 31 ++++++++++ sdk/context_test.go | 77 +++++++++++++++++++++++++ sdk/errors.go | 11 ++++ 13 files changed, 217 insertions(+), 102 deletions(-) create mode 100644 go.work create mode 100644 sdk/context_test.go create mode 100644 sdk/errors.go diff --git a/.gitignore b/.gitignore index 2b45946..5c93ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,6 @@ *.out coverage.html -# Go workspace file -go.work -go.work.sum - # Dependency directories vendor/ diff --git a/docs/reference/contrib-plugins.md b/docs/reference/contrib-plugins.md index 347cc05..bcd1260 100644 --- a/docs/reference/contrib-plugins.md +++ b/docs/reference/contrib-plugins.md @@ -205,10 +205,10 @@ Maps a [`TransactionContext`][tx] to a credential key (e.g., a tenant ID, sessio A successful return must be a non-empty string. Return [`ErrMissingContextData`](#sentinel-errors) if the transaction lacks enough information to resolve a key. -### `ResolveFromContext` +### `ResolveCredentialKey` ```go -func ResolveFromContext( +func ResolveCredentialKey( ctx context.Context, tx sdk.TransactionContext, dataField string, @@ -216,7 +216,7 @@ func ResolveFromContext( ) (string, error) ``` -Shared helper that providers call instead of reading `tx.Data` directly. It implements a strict fallback chain: +Shared helper for credential-selection keys (for example, Microsoft `TenantID`). It implements a strict fallback chain: 1. If `tx.Data[dataField]` is present and is a valid non-empty string, return it (explicit connector override). 2. If `tx.Data[dataField]` is present but has the wrong type or is empty, return [`ErrInvalidContextData`](#sentinel-errors). Malformed overrides never fall through to the resolver. @@ -622,8 +622,8 @@ Resolves `TenantID` and `Resource` from the transaction context and returns a ca | Key | Type | Resolution | Description | |-----|------|------------|-------------| -| `"TenantID"` | `string` | `tx.Data` override → [`KeyResolver`](#keyresolver) → error | Azure AD tenant (e.g., `"contoso.onmicrosoft.com"`). If present in `tx.Data`, that value is used (connector override). If absent, the configured `KeyResolver` is called. If no resolver is configured, returns [`ErrMissingContextData`](#sentinel-errors). Malformed overrides (wrong type, empty) return [`ErrInvalidContextData`](#sentinel-errors) and never fall through to the resolver. The resolved value must match `^[a-zA-Z0-9][a-zA-Z0-9.\-]*$` regardless of source. | -| `"Resource"` | `string` | `tx.Data` only | Target resource (e.g., `"https://graph.microsoft.com"`). Always required in `tx.Data` — no resolver fallback. This is a per-request concern (which API the connector is calling). Returns [`ErrMissingContextData`](#sentinel-errors) if absent, [`ErrInvalidContextData`](#sentinel-errors) if not a string or empty. | +| `"TenantID"` | `string` | `tx.Data` override → [`KeyResolver`](#keyresolver) → error | Azure AD tenant (e.g., `"contoso.onmicrosoft.com"`). Implemented via `ResolveCredentialKey`. If present in `tx.Data`, that value is used (connector override). If absent, the configured `KeyResolver` is called. If no resolver is configured, returns [`ErrMissingContextData`](#sentinel-errors). Malformed overrides (wrong type, empty) return [`ErrInvalidContextData`](#sentinel-errors) and never fall through to the resolver. The resolved value must match `^[a-zA-Z0-9][a-zA-Z0-9.\-]*$` regardless of source. | +| `"Resource"` | `string` | `tx.Data` only | Target resource (e.g., `"https://graph.microsoft.com"`). Implemented via `DataString` plus explicit missing-field handling in the provider. Always required in `tx.Data` — no resolver fallback. This is a per-request concern (which API the connector is calling). Returns [`ErrMissingContextData`](#sentinel-errors) if absent, [`ErrInvalidContextData`](#sentinel-errors) if not a string or empty. | **Token endpoint URL construction:** diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index b4f09d9..3b09804 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -176,6 +176,29 @@ type TransactionContext struct { | `SubscriptionID` | `string` | Subscription identifier. | | `TargetURL` | `string` | Destination URL for this request. Already validated against the allow-list by the Core. | +#### Helper Methods + +##### `DataString` + +```go +func (tx TransactionContext) DataString(field string) (value string, ok bool, err error) +``` + +Returns the `tx.Data[field]` string value when present and valid. + +**Return values:** +- `value`: the string when present and valid +- `ok`: `true` when the field is present, `false` when absent +- `err`: `ErrInvalidContextData` when present but wrong type or empty + +**Behavior:** + +1. If `tx.Data[field]` is present and is a valid non-empty string, returns `(value, true, nil)`. +2. If present but has the wrong type or is an empty string, returns `("", true, ErrInvalidContextData)`. +3. If absent, returns `("", false, nil)`. + +Use this helper to validate optional/required fields while keeping "missing field" policy at the call site (see [DataString usage example](../tutorials/microsoft-sam-mux.md#handling-context-data)). + #### Header Mapping These fields are extracted from headers using the configured prefix @@ -258,7 +281,27 @@ type ResponseAction struct { --- -## Public API +## Errors + +### `ErrInvalidContextData` + +```go +var ErrInvalidContextData = errors.New("invalid context data type") +``` + +Indicates a transaction context field is present but fails validation (wrong type or empty string). +Used by [`DataString`](#datastring) and other context validation functions. + +Check with [`errors.Is`](https://pkg.go.dev/errors#Is): + +```go +value, ok, err := tx.DataString("TenantID") +if errors.Is(err, sdk.ErrInvalidContextData) { + // Field present but invalid (wrong type or empty) +} +``` + +--- The Core module (`github.com/cloudblue/chaperone`) provides the entry points for running the proxy. diff --git a/docs/tutorials/microsoft-sam-mux.md b/docs/tutorials/microsoft-sam-mux.md index 0eb65a6..5b9a87e 100644 --- a/docs/tutorials/microsoft-sam-mux.md +++ b/docs/tutorials/microsoft-sam-mux.md @@ -211,6 +211,7 @@ The proxy handles the full auth lifecycle: ## Going further - **Resolve TenantID automatically** — Instead of requiring `TenantID` in every request's context data, use a [`KeyResolver`](../reference/contrib-plugins.md#keyresolver) to map transaction fields (marketplace, vendor) to the correct tenant. The built-in [`StaticMapping`](../reference/contrib-plugins.md#staticmapping) provides a declarative rule table — see the [reference](../reference/contrib-plugins.md#staticmapping) for configuration details. + `TenantID` supports resolver fallback; `Resource` remains a required explicit value in `X-Connect-Context-Data` for each request. - **Add more tenants** — Run `chaperone-onboard microsoft` for each tenant and place the token file in the `tokens/` directory. One onboarding per tenant (MRRT). The `RefreshTokenSource` manages an LRU pool of per-tenant entries automatically. - **Multiple app registrations** — If different groups of tenants require separate Azure AD app registrations (e.g., one per region or partner program), create a `RefreshTokenSource` per app and route them through the Mux. Each source gets its own `KeyResolver` for tenant resolution. All sources can share a single `FileStore` because tokens are keyed by tenant, not by app registration. See the [multiple app registrations example](../reference/contrib-plugins.md#multiple-microsoft-app-registrations) in the contrib reference for complete code. diff --git a/go.work b/go.work new file mode 100644 index 0000000..1f755cd --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.26.1 + +use ( + . + ./plugins/contrib + ./sdk +) diff --git a/plugins/contrib/errors.go b/plugins/contrib/errors.go index c9c4e88..630147f 100644 --- a/plugins/contrib/errors.go +++ b/plugins/contrib/errors.go @@ -23,12 +23,6 @@ var ErrNoRouteMatch = errors.New("no route matched") // the Connect platform sent a request without the expected context headers. var ErrMissingContextData = errors.New("missing required context data") -// ErrInvalidContextData indicates a required key is present in -// TransactionContext.Data but has the wrong type (e.g., TenantID is a -// number instead of a string). Since Data is map[string]any from JSON -// unmarshaling, type assertions can fail. This is a platform/caller issue. -var ErrInvalidContextData = errors.New("invalid context data type") - // ErrTenantNotFound indicates the requested tenant is not in the static // config and no resolver callback is set, or the resolver returned not found. // This is a proxy configuration issue. diff --git a/plugins/contrib/microsoft/token.go b/plugins/contrib/microsoft/token.go index 46f90a1..e3b28af 100644 --- a/plugins/contrib/microsoft/token.go +++ b/plugins/contrib/microsoft/token.go @@ -201,20 +201,24 @@ func (s *RefreshTokenSource) GetCredentials( tx sdk.TransactionContext, _ *http.Request, ) (*sdk.Credential, error) { - tenantID, err := contrib.ResolveFromContext(ctx, tx, "TenantID", s.keyResolver) + tenantID, err := contrib.ResolveCredentialKey(ctx, tx, "TenantID", s.keyResolver) if err != nil { return nil, err } if !validTenantID.MatchString(tenantID) { return nil, fmt.Errorf("TenantID contains invalid characters: %w", - contrib.ErrInvalidContextData) + sdk.ErrInvalidContextData) } - resource, err := contrib.ResolveFromContext(ctx, tx, "Resource", nil) + resource, ok, err := tx.DataString("Resource") if err != nil { return nil, err } + if !ok { + return nil, fmt.Errorf("Resource not present in transaction context: %w", //nolint:staticcheck // Resource is an SDK context key identifier, so ignore ST1005 (capitalized error string) + contrib.ErrMissingContextData) + } entry := s.getOrCreate(ctx, tenantID) diff --git a/plugins/contrib/microsoft/token_test.go b/plugins/contrib/microsoft/token_test.go index f276911..ee6196f 100644 --- a/plugins/contrib/microsoft/token_test.go +++ b/plugins/contrib/microsoft/token_test.go @@ -236,8 +236,8 @@ func TestGetCredentials_EmptyTenantID_ReturnsErrInvalidContextData(t *testing.T) t.Fatal("expected error") } - if !errors.Is(err, contrib.ErrInvalidContextData) { - t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) + if !errors.Is(err, sdk.ErrInvalidContextData) { + t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err) } if !strings.Contains(err.Error(), "TenantID") { @@ -271,8 +271,8 @@ func TestGetCredentials_EmptyResource_ReturnsErrInvalidContextData(t *testing.T) t.Fatal("expected error") } - if !errors.Is(err, contrib.ErrInvalidContextData) { - t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) + if !errors.Is(err, sdk.ErrInvalidContextData) { + t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err) } if !strings.Contains(err.Error(), "Resource") { @@ -302,8 +302,8 @@ func TestGetCredentials_TenantIDWrongType_ReturnsErrInvalidContextData(t *testin t.Fatal("expected error") } - if !errors.Is(err, contrib.ErrInvalidContextData) { - t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) + if !errors.Is(err, sdk.ErrInvalidContextData) { + t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err) } if !strings.Contains(err.Error(), "float64") { @@ -349,7 +349,7 @@ func TestGetCredentials_MaliciousTenantID_ReturnsErrInvalidContextData(t *testin t.Fatalf("expected error for tenantID %q", tt.tenantID) } - if !errors.Is(err, contrib.ErrInvalidContextData) { + if !errors.Is(err, sdk.ErrInvalidContextData) { t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) } }) @@ -1359,7 +1359,7 @@ func TestGetCredentials_KeyResolver_MalformedTxDataErrorsEvenWithResolver(t *tes t.Fatal("expected error") } - if !errors.Is(err, contrib.ErrInvalidContextData) { + if !errors.Is(err, sdk.ErrInvalidContextData) { t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) } }) @@ -1442,7 +1442,7 @@ func TestGetCredentials_KeyResolver_ValidTenantIDCheck_RejectsBadResolverValue(t t.Fatal("expected error for path traversal tenant from resolver") } - if !errors.Is(err, contrib.ErrInvalidContextData) { + if !errors.Is(err, sdk.ErrInvalidContextData) { t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) } } diff --git a/plugins/contrib/resolver.go b/plugins/contrib/resolver.go index 617af0a..0281867 100644 --- a/plugins/contrib/resolver.go +++ b/plugins/contrib/resolver.go @@ -23,7 +23,7 @@ type KeyResolver interface { ResolveKey(ctx context.Context, tx sdk.TransactionContext) (string, error) } -// ResolveFromContext extracts a key from tx.Data if present, otherwise +// ResolveCredentialKey extracts a key from tx.Data if present, otherwise // delegates to the resolver. Returns ErrMissingContextData if neither // source provides a value. // @@ -31,15 +31,18 @@ type KeyResolver interface { // return ErrInvalidContextData — they never fall through to the resolver. // This preserves strictness: a connector bug that sends a bad value is // surfaced immediately, not silently masked by the resolver. -func ResolveFromContext( +func ResolveCredentialKey( ctx context.Context, tx sdk.TransactionContext, dataField string, resolver KeyResolver, ) (string, error) { - raw, ok := tx.Data[dataField] + key, ok, err := tx.DataString(dataField) + if err != nil { + return "", err + } if ok { - return extractOverride(raw, dataField) + return key, nil } if resolver != nil { @@ -57,21 +60,3 @@ func ResolveFromContext( return "", fmt.Errorf("%s not present in transaction context: %w", dataField, ErrMissingContextData) } - -// extractOverride validates an explicit override from tx.Data. It returns -// ErrInvalidContextData for wrong type or empty string — these never fall -// through to the resolver. -func extractOverride(raw any, field string) (string, error) { - s, ok := raw.(string) - if !ok { - return "", fmt.Errorf("%s must be a string, got %T: %w", - field, raw, ErrInvalidContextData) - } - - if s == "" { - return "", fmt.Errorf("%s is empty in transaction context: %w", - field, ErrInvalidContextData) - } - - return s, nil -} diff --git a/plugins/contrib/resolver_test.go b/plugins/contrib/resolver_test.go index 76261c0..477de81 100644 --- a/plugins/contrib/resolver_test.go +++ b/plugins/contrib/resolver_test.go @@ -23,12 +23,12 @@ func (m *mockResolver) ResolveKey(_ context.Context, _ sdk.TransactionContext) ( return m.key, m.err } -func TestResolveFromContext_PresentValidString(t *testing.T) { +func TestResolveCredentialKey_PresentValidString(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{"TenantID": "contoso.onmicrosoft.com"}, } - got, err := ResolveFromContext(context.Background(), tx, "TenantID", nil) + got, err := ResolveCredentialKey(context.Background(), tx, "TenantID", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -38,48 +38,14 @@ func TestResolveFromContext_PresentValidString(t *testing.T) { } } -func TestResolveFromContext_PresentEmptyString_ReturnsErrInvalidContextData(t *testing.T) { - tx := sdk.TransactionContext{ - Data: map[string]any{"TenantID": ""}, - } - - resolver := &mockResolver{key: "should-not-be-used"} - - _, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) - if err == nil { - t.Fatal("expected error") - } - - if !errors.Is(err, ErrInvalidContextData) { - t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) - } -} - -func TestResolveFromContext_PresentWrongType_ReturnsErrInvalidContextData(t *testing.T) { - tx := sdk.TransactionContext{ - Data: map[string]any{"TenantID": float64(12345)}, - } - - resolver := &mockResolver{key: "should-not-be-used"} - - _, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) - if err == nil { - t.Fatal("expected error") - } - - if !errors.Is(err, ErrInvalidContextData) { - t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) - } -} - -func TestResolveFromContext_AbsentWithResolver(t *testing.T) { +func TestResolveCredentialKey_AbsentWithResolver(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{"OtherField": "value"}, } resolver := &mockResolver{key: "resolved-tenant"} - got, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) + got, err := ResolveCredentialKey(context.Background(), tx, "TenantID", resolver) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -89,12 +55,12 @@ func TestResolveFromContext_AbsentWithResolver(t *testing.T) { } } -func TestResolveFromContext_AbsentWithoutResolver_ReturnsErrMissingContextData(t *testing.T) { +func TestResolveCredentialKey_AbsentWithoutResolver_ReturnsErrMissingContextData(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{}, } - _, err := ResolveFromContext(context.Background(), tx, "TenantID", nil) + _, err := ResolveCredentialKey(context.Background(), tx, "TenantID", nil) if err == nil { t.Fatal("expected error") } @@ -104,12 +70,12 @@ func TestResolveFromContext_AbsentWithoutResolver_ReturnsErrMissingContextData(t } } -func TestResolveFromContext_NilDataWithResolver(t *testing.T) { +func TestResolveCredentialKey_NilDataWithResolver(t *testing.T) { tx := sdk.TransactionContext{Data: nil} resolver := &mockResolver{key: "resolved-tenant"} - got, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) + got, err := ResolveCredentialKey(context.Background(), tx, "TenantID", resolver) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -119,10 +85,10 @@ func TestResolveFromContext_NilDataWithResolver(t *testing.T) { } } -func TestResolveFromContext_NilDataWithoutResolver(t *testing.T) { +func TestResolveCredentialKey_NilDataWithoutResolver(t *testing.T) { tx := sdk.TransactionContext{Data: nil} - _, err := ResolveFromContext(context.Background(), tx, "TenantID", nil) + _, err := ResolveCredentialKey(context.Background(), tx, "TenantID", nil) if err == nil { t.Fatal("expected error") } @@ -132,14 +98,14 @@ func TestResolveFromContext_NilDataWithoutResolver(t *testing.T) { } } -func TestResolveFromContext_ResolverReturnsEmptyString_ReturnsErrMissingContextData(t *testing.T) { +func TestResolveCredentialKey_ResolverReturnsEmptyString_ReturnsErrMissingContextData(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{}, } resolver := &mockResolver{key: ""} - _, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) + _, err := ResolveCredentialKey(context.Background(), tx, "TenantID", resolver) if err == nil { t.Fatal("expected error for empty resolver return") } @@ -149,7 +115,7 @@ func TestResolveFromContext_ResolverReturnsEmptyString_ReturnsErrMissingContextD } } -func TestResolveFromContext_ResolverErrorWrappedWithFieldContext(t *testing.T) { +func TestResolveCredentialKey_ResolverErrorWrappedWithFieldContext(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{}, } @@ -157,7 +123,7 @@ func TestResolveFromContext_ResolverErrorWrappedWithFieldContext(t *testing.T) { resolverErr := fmt.Errorf("lookup failed: %w", ErrTenantNotFound) resolver := &mockResolver{err: resolverErr} - _, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) + _, err := ResolveCredentialKey(context.Background(), tx, "TenantID", resolver) if err == nil { t.Fatal("expected error") } @@ -167,7 +133,7 @@ func TestResolveFromContext_ResolverErrorWrappedWithFieldContext(t *testing.T) { } } -func TestResolveFromContext_ResolverErrorPropagated(t *testing.T) { +func TestResolveCredentialKey_ResolverErrorPropagated(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{}, } @@ -175,7 +141,7 @@ func TestResolveFromContext_ResolverErrorPropagated(t *testing.T) { resolverErr := fmt.Errorf("lookup failed: %w", ErrTenantNotFound) resolver := &mockResolver{err: resolverErr} - _, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) + _, err := ResolveCredentialKey(context.Background(), tx, "TenantID", resolver) if err == nil { t.Fatal("expected error") } @@ -185,14 +151,14 @@ func TestResolveFromContext_ResolverErrorPropagated(t *testing.T) { } } -func TestResolveFromContext_PresentOverrideIgnoresResolver(t *testing.T) { +func TestResolveCredentialKey_PresentOverrideIgnoresResolver(t *testing.T) { tx := sdk.TransactionContext{ Data: map[string]any{"TenantID": "explicit-tenant"}, } resolver := &mockResolver{key: "resolved-tenant"} - got, err := ResolveFromContext(context.Background(), tx, "TenantID", resolver) + got, err := ResolveCredentialKey(context.Background(), tx, "TenantID", resolver) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/sdk/context.go b/sdk/context.go index e221d9f..124f41d 100644 --- a/sdk/context.go +++ b/sdk/context.go @@ -3,6 +3,8 @@ package sdk +import "fmt" + // TransactionContext contains the metadata for a single proxy request. // // This information is extracted from the inbound request headers @@ -39,3 +41,32 @@ type TransactionContext struct { // This has already been validated against the allow-list by the core. TargetURL string } + +// DataString returns the tx.Data[field] string value when present. +// +// Return values: +// - value: the string when present and valid +// - ok: true when the field is present, false when absent +// - err: ErrInvalidContextData when present but wrong type or empty +// +// This is a utility for plugin implementations to safely extract +// string values from the transaction context data with proper validation. +func (tx TransactionContext) DataString(field string) (value string, ok bool, err error) { + raw, found := tx.Data[field] + if !found { + return "", false, nil + } + + s, strOK := raw.(string) + if !strOK { + return "", true, fmt.Errorf("%s must be a string, got %T: %w", + field, raw, ErrInvalidContextData) + } + + if s == "" { + return "", true, fmt.Errorf("%s is empty in transaction context: %w", + field, ErrInvalidContextData) + } + + return s, true, nil +} diff --git a/sdk/context_test.go b/sdk/context_test.go new file mode 100644 index 0000000..b491688 --- /dev/null +++ b/sdk/context_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package sdk + +import ( + "errors" + "testing" +) + +func TestDataString_PresentValidString(t *testing.T) { + tx := TransactionContext{ + Data: map[string]any{"TenantID": "contoso.onmicrosoft.com"}, + } + + got, ok, err := tx.DataString("TenantID") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected field to be present") + } + + if got != "contoso.onmicrosoft.com" { + t.Errorf("got %q, want %q", got, "contoso.onmicrosoft.com") + } +} + +func TestDataString_PresentEmptyString_ReturnsErrInvalidContextData(t *testing.T) { + tx := TransactionContext{ + Data: map[string]any{"TenantID": ""}, + } + + _, ok, err := tx.DataString("TenantID") + if err == nil { + t.Fatal("expected error") + } + if !ok { + t.Fatal("expected field to be present") + } + + if !errors.Is(err, ErrInvalidContextData) { + t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) + } +} + +func TestDataString_PresentWrongType_ReturnsErrInvalidContextData(t *testing.T) { + tx := TransactionContext{ + Data: map[string]any{"TenantID": float64(12345)}, + } + + _, ok, err := tx.DataString("TenantID") + if err == nil { + t.Fatal("expected error") + } + if !ok { + t.Fatal("expected field to be present") + } + + if !errors.Is(err, ErrInvalidContextData) { + t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err) + } +} + +func TestDataString_Absent_ReturnsNotPresent(t *testing.T) { + tx := TransactionContext{ + Data: map[string]any{"OtherField": "value"}, + } + + _, ok, err := tx.DataString("TenantID") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected field to be absent") + } +} diff --git a/sdk/errors.go b/sdk/errors.go new file mode 100644 index 0000000..b2c2f0c --- /dev/null +++ b/sdk/errors.go @@ -0,0 +1,11 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package sdk + +import "errors" + +// ErrInvalidContextData indicates a transaction context field is present +// but fails validation (wrong type or empty string). +// Used by DataString and other context validation functions. +var ErrInvalidContextData = errors.New("invalid context data type")