Skip to content
Draft
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
13 changes: 12 additions & 1 deletion .github/actions/check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,20 @@ runs:
shell: bash
run: make start-contract-test-service-bg

- uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.2.0
- name: Run contract tests v2
if: ${{ inputs.flaky != 'true' }}
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
with:
test_service_port: 9000
enable_persistence_tests: true
token: ${{ inputs.token }}
stop_service: 'false'

- name: Run contract tests v3
if: ${{ inputs.flaky != 'true' }}
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
with:
test_service_port: 9000
enable_persistence_tests: true
token: ${{ inputs.token }}
version: v3.0.0-alpha.1
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ start-contract-test-service-bg:

run-contract-tests:
@curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \
| VERSION=v2 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh
| VERSION=v3.0.0-alpha.1 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh

contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests

Expand Down
250 changes: 173 additions & 77 deletions contract-tests/client_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,55 +14,97 @@ def initialize(log, config)

opts[:logger] = log

if config[:streaming]
data_system_config = config[:dataSystem]
if data_system_config
data_system = LaunchDarkly::DataSystem.custom

if config[:persistentDataStore]
store, store_mode = build_persistent_store(config[:persistentDataStore])
data_system.data_store(store, store_mode)
end

init_configs = data_system_config[:initializers]
if init_configs
initializers = []
init_configs.each do |init_config|
polling = init_config[:polling]
next unless polling

opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
initializers << LaunchDarkly::DataSystem.polling_ds_builder
end
data_system.initializers(initializers)
end

sync_config = data_system_config[:synchronizers]
if sync_config
primary = sync_config[:primary]
secondary = sync_config[:secondary]

primary_builder = nil
secondary_builder = nil

if primary
streaming = primary[:streaming]
if streaming
opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri]
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
primary_builder = LaunchDarkly::DataSystem.streaming_ds_builder
elsif primary[:polling]
polling = primary[:polling]
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
primary_builder = LaunchDarkly::DataSystem.polling_ds_builder
end
end

if secondary
streaming = secondary[:streaming]
if streaming
opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri]
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
secondary_builder = LaunchDarkly::DataSystem.streaming_ds_builder
elsif secondary[:polling]
polling = secondary[:polling]
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
secondary_builder = LaunchDarkly::DataSystem.polling_ds_builder
end
end

data_system.synchronizers(primary_builder, secondary_builder) if primary_builder

if primary_builder || secondary_builder
fallback_builder = LaunchDarkly::DataSystem.fdv1_fallback_ds_builder
data_system.fdv1_compatible_synchronizer(fallback_builder)
end
end

if data_system_config[:payloadFilter]
opts[:payload_filter_key] = data_system_config[:payloadFilter]
end

opts[:data_system_config] = data_system.build
elsif config[:streaming]
streaming = config[:streaming]
opts[:stream_uri] = streaming[:baseUri] unless streaming[:baseUri].nil?
opts[:payload_filter_key] = streaming[:filter] unless streaming[:filter].nil?
opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 unless streaming[:initialRetryDelayMs].nil?
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
elsif config[:polling]
polling = config[:polling]
opts[:stream] = false
opts[:base_uri] = polling[:baseUri] unless polling[:baseUri].nil?
opts[:payload_filter_key] = polling[:filter] unless polling[:filter].nil?
opts[:poll_interval] = polling[:pollIntervalMs] / 1_000.0 unless polling[:pollIntervalMs].nil?
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
else
opts[:use_ldd] = true
end

if config[:persistentDataStore]
store_config = {}
store_config[:prefix] = config[:persistentDataStore][:store][:prefix] if config[:persistentDataStore][:store][:prefix]

case config[:persistentDataStore][:cache][:mode]
when 'off'
store_config[:expiration] = 0
when 'infinite'
# NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default
# 15 second expiration time in the SDK, which is long enough to pass any test.
store_config[:expiration] = nil
when 'ttl'
store_config[:expiration] = config[:persistentDataStore][:cache][:ttl]
end

case config[:persistentDataStore][:store][:type]
when 'redis'
store_config[:redis_url] = config[:persistentDataStore][:store][:dsn]
store = LaunchDarkly::Integrations::Redis.new_feature_store(store_config)
opts[:feature_store] = store
when 'consul'
store_config[:url] = config[:persistentDataStore][:store][:url]
store = LaunchDarkly::Integrations::Consul.new_feature_store(store_config)
opts[:feature_store] = store
when 'dynamodb'
client = Aws::DynamoDB::Client.new(
region: 'us-east-1',
credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'),
endpoint: config[:persistentDataStore][:store][:dsn]
)
store_config[:existing_client] = client
store = LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config)
opts[:feature_store] = store
end
# Configure persistent data store for legacy (non-dataSystem) configurations
if !data_system_config && config[:persistentDataStore]
store, store_mode = build_persistent_store(config[:persistentDataStore])
opts[:feature_store] = store
end

if config[:events]
Expand All @@ -72,7 +114,7 @@ def initialize(log, config)
opts[:diagnostic_opt_out] = !events[:enableDiagnostics]
opts[:all_attributes_private] = !!events[:allAttributesPrivate]
opts[:private_attributes] = events[:globalPrivateAttributes]
opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) unless events[:flushIntervalMs].nil?
set_optional_time_prop(events, :flushIntervalMs, opts, :flush_interval)
opts[:omit_anonymous_contexts] = !!events[:omitAnonymousContexts]
opts[:compress_events] = !!events[:enableGzip]
else
Expand All @@ -81,19 +123,14 @@ def initialize(log, config)

if config[:bigSegments]
big_segments = config[:bigSegments]
big_config = { store: BigSegmentStoreFixture.new(big_segments[:callbackUri]) }

big_config[:context_cache_size] = big_segments[:userCacheSize] if big_segments[:userCacheSize]
set_optional_time_prop(big_segments, :userCacheTimeMs, big_config, :context_cache_time)
set_optional_time_prop(big_segments, :statusPollIntervalMs, big_config, :status_poll_interval)
set_optional_time_prop(big_segments, :staleAfterMs, big_config, :stale_after)

store = BigSegmentStoreFixture.new(config[:bigSegments][:callbackUri])
context_cache_time = big_segments[:userCacheTimeMs].nil? ? nil : big_segments[:userCacheTimeMs] / 1_000
status_poll_interval_ms = big_segments[:statusPollIntervalMs].nil? ? nil : big_segments[:statusPollIntervalMs] / 1_000
stale_after_ms = big_segments[:staleAfterMs].nil? ? nil : big_segments[:staleAfterMs] / 1_000

opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(
store: store,
context_cache_size: big_segments[:userCacheSize],
context_cache_time: context_cache_time,
status_poll_interval: status_poll_interval_ms,
stale_after: stale_after_ms
)
opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(**big_config)
end

if config[:tags]
Expand Down Expand Up @@ -198,6 +235,50 @@ def context_comparison(params)
context1 == context2
end

def secure_mode_hash(params)
@client.secure_mode_hash(params[:context])
end

def track(params)
@client.track(params[:eventKey], params[:context], params[:data], params[:metricValue])
end

def identify(params)
@client.identify(params[:context])
end

def flush_events
@client.flush
end

def get_big_segment_store_status
status = @client.big_segment_store_status_provider.status
{ available: status.available, stale: status.stale }
end

def log
@log
end

def close
@client.close
@log.info("Test ended")
end

#
# Helper to convert millisecond time properties to seconds.
# Only sets the output if the input value is present.
#
# @param params_in [Hash] Input parameters hash
# @param name_in [Symbol] Key name in input hash (e.g., :pollIntervalMs)
# @param params_out [Hash] Output parameters hash
# @param name_out [Symbol] Key name in output hash (e.g., :poll_interval)
#
private def set_optional_time_prop(params_in, name_in, params_out, name_out)
value = params_in[name_in]
params_out[name_out] = value / 1_000.0 if value
end

private def build_context_from_params(params)
return build_single_context_from_attribute_definitions(params[:single]) unless params[:single].nil?

Expand Down Expand Up @@ -230,33 +311,48 @@ def context_comparison(params)
LaunchDarkly::LDContext.create(context)
end

def secure_mode_hash(params)
@client.secure_mode_hash(params[:context])
end

def track(params)
@client.track(params[:eventKey], params[:context], params[:data], params[:metricValue])
end

def identify(params)
@client.identify(params[:context])
end

def flush_events
@client.flush
end

def get_big_segment_store_status
status = @client.big_segment_store_status_provider.status
{ available: status.available, stale: status.stale }
end

def log
@log
end
#
# Builds a persistent data store from the contract test configuration.
#
# @param persistent_store_config [Hash] The persistentDataStore configuration
# @return [Array<Object, Symbol>] Returns [store, store_mode]
#
private def build_persistent_store(persistent_store_config)
store_config = {}
store_config[:prefix] = persistent_store_config[:store][:prefix] if persistent_store_config[:store][:prefix]

case persistent_store_config[:cache][:mode]
when 'off'
store_config[:expiration] = 0
when 'infinite'
# NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default
# 15 second expiration time in the SDK, which is long enough to pass any test.
store_config[:expiration] = nil
when 'ttl'
store_config[:expiration] = persistent_store_config[:cache][:ttl]
end

def close
@client.close
@log.info("Test ended")
store = case persistent_store_config[:store][:type]
when 'redis'
store_config[:redis_url] = persistent_store_config[:store][:dsn]
LaunchDarkly::Integrations::Redis.new_feature_store(store_config)
when 'consul'
store_config[:url] = persistent_store_config[:store][:url]
LaunchDarkly::Integrations::Consul.new_feature_store(store_config)
when 'dynamodb'
client = Aws::DynamoDB::Client.new(
region: 'us-east-1',
credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'),
endpoint: persistent_store_config[:store][:dsn]
)
store_config[:existing_client] = client
LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config)
end

# Determine store mode based on whether it's read-write or read-only
# For contract tests with data sources (streaming/polling), stores are read-write
store_mode = LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE

[store, store_mode]
end
end
4 changes: 2 additions & 2 deletions lib/ldclient-rb/impl/data_system/polling.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ module DataSystem
FDV2_POLLING_ENDPOINT = "/sdk/poll"
FDV1_POLLING_ENDPOINT = "/sdk/latest-all"

LD_ENVID_HEADER = "x-launchdarkly-env-id"
LD_FD_FALLBACK_HEADER = "x-launchdarkly-fd-fallback"
LD_ENVID_HEADER = "X-LD-EnvID"
LD_FD_FALLBACK_HEADER = "X-LD-FD-Fallback"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header lookup fails due to case mismatch

High Severity

The LD_ENVID_HEADER and LD_FD_FALLBACK_HEADER constants were changed from lowercase ("x-launchdarkly-env-id", "x-launchdarkly-fd-fallback") to mixed-case ("X-LD-EnvID", "X-LD-FD-Fallback"). However, HTTP response headers are explicitly downcased via transform_keys(&:downcase) when constructing response_headers. Since Ruby hash lookups are case-sensitive, lookups using these mixed-case constants against the all-lowercase headers hash will always return nil, breaking fallback detection and environment ID retrieval.

Fix in Cursor Fix in Web


#
# Requester protocol for polling data source
Expand Down
Loading