quonfig 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +1 -1
- data/README.md +4 -4
- data/VERSION +1 -1
- data/lib/quonfig/bound_client.rb +26 -0
- data/lib/quonfig/client.rb +104 -2
- data/lib/quonfig/dev_context.rb +41 -0
- data/lib/quonfig/evaluation_details.rb +60 -0
- data/lib/quonfig/evaluator.rb +21 -2
- data/lib/quonfig/options.rb +41 -17
- data/lib/quonfig/resolver.rb +30 -2
- data/lib/quonfig/telemetry/telemetry_reporter.rb +13 -1
- data/lib/quonfig.rb +2 -0
- data/quonfig.gemspec +8 -3
- data/test/integration/test_dev_overrides.rb +40 -0
- data/test/integration/test_helpers.rb +34 -1
- data/test/integration/test_telemetry.rb +14 -0
- data/test/test_client_telemetry.rb +43 -0
- data/test/test_details_getters.rb +242 -0
- data/test/test_dev_context.rb +163 -0
- data/test/test_http_connection.rb +6 -4
- data/test/test_options.rb +53 -22
- metadata +7 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# AUTO-GENERATED from integration-test-data/tests/eval/dev_overrides.yaml.
|
|
4
|
+
# Regenerate with:
|
|
5
|
+
# cd integration-test-data/generators && npm run generate -- --target=ruby
|
|
6
|
+
# Source: integration-test-data/generators/src/targets/ruby.ts
|
|
7
|
+
# Do NOT edit by hand — changes will be overwritten.
|
|
8
|
+
|
|
9
|
+
require 'test_helper'
|
|
10
|
+
require 'integration/test_helpers'
|
|
11
|
+
|
|
12
|
+
class TestDevOverrides < Minitest::Test
|
|
13
|
+
def setup
|
|
14
|
+
@store = IntegrationTestHelpers.build_store("dev_overrides")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# override fires when quonfig-user.email matches
|
|
18
|
+
def test_override_fires_when_quonfig_user_email_matches
|
|
19
|
+
resolver = IntegrationTestHelpers.build_resolver(@store)
|
|
20
|
+
IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override", {"quonfig-user" => {"email" => "bob@foo.com"}}, true)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# override does not fire when attribute absent (prod simulation)
|
|
24
|
+
def test_override_does_not_fire_when_attribute_absent_prod_simulation
|
|
25
|
+
resolver = IntegrationTestHelpers.build_resolver(@store)
|
|
26
|
+
IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override", {"user" => {"email" => "bob@foo.com"}}, false)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# override matches any email in IS_ONE_OF list
|
|
30
|
+
def test_override_matches_any_email_in_is_one_of_list
|
|
31
|
+
resolver = IntegrationTestHelpers.build_resolver(@store)
|
|
32
|
+
IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override.multi-email", {"quonfig-user" => {"email" => "alice@foo.com"}}, true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# override beats customer rule by priority
|
|
36
|
+
def test_override_beats_customer_rule_by_priority
|
|
37
|
+
resolver = IntegrationTestHelpers.build_resolver(@store)
|
|
38
|
+
IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override.priority", {"quonfig-user" => {"email" => "bob@foo.com"}, "user" => {"country" => "DE"}}, true)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -290,6 +290,13 @@ module IntegrationTestHelpers
|
|
|
290
290
|
# so eval-summary cases can resolve real values for each key.
|
|
291
291
|
class << self
|
|
292
292
|
attr_accessor :last_store
|
|
293
|
+
# Side-channel populated by record_one_eval whenever we redact a
|
|
294
|
+
# confidential value before recording it on the aggregator. Keyed by
|
|
295
|
+
# config_key → { unwrapped:, value_type: }. evaluation_summary_post
|
|
296
|
+
# consults it so the YAML's `value` / `value_type` fields can still
|
|
297
|
+
# assert the runtime resolved value while `selected_value` carries
|
|
298
|
+
# the wire-redacted form.
|
|
299
|
+
attr_accessor :last_unwrapped_overrides
|
|
293
300
|
end
|
|
294
301
|
|
|
295
302
|
# Construct an aggregator. +kind+ is one of :context_shape,
|
|
@@ -465,6 +472,7 @@ module IntegrationTestHelpers
|
|
|
465
472
|
ctx = contexts.is_a?(Quonfig::Context) ? contexts : Quonfig::Context.new(contexts || {})
|
|
466
473
|
empty_ctx = Quonfig::Context.new({})
|
|
467
474
|
|
|
475
|
+
self.last_unwrapped_overrides = {}
|
|
468
476
|
Array(keys).each { |key| record_one_eval(aggregator, resolver, store, key, ctx) }
|
|
469
477
|
Array(keys_no_ctx).each { |key| record_one_eval(aggregator, resolver, store, key, empty_ctx) }
|
|
470
478
|
end
|
|
@@ -482,13 +490,28 @@ module IntegrationTestHelpers
|
|
|
482
490
|
end
|
|
483
491
|
return if result.nil?
|
|
484
492
|
|
|
493
|
+
# Confidential / decryptWith values must never appear in plaintext on
|
|
494
|
+
# the wire. EvalResult#reportable_value, when populated, is the
|
|
495
|
+
# `*****<md5>`-redacted substitute the resolver computed pre-decryption.
|
|
496
|
+
# When we substitute, stash the runtime unwrapped value so the
|
|
497
|
+
# post-projection can still assert YAML's `value` / `value_type` against
|
|
498
|
+
# the resolved plaintext (the YAML treats `value` as the runtime view
|
|
499
|
+
# and `selected_value` as the wire view).
|
|
500
|
+
selected_for_telemetry = result.unwrapped_value
|
|
501
|
+
if result.reportable_value
|
|
502
|
+
selected_for_telemetry = result.reportable_value
|
|
503
|
+
(self.last_unwrapped_overrides ||= {})[key] = {
|
|
504
|
+
unwrapped: result.unwrapped_value,
|
|
505
|
+
value_type: result.value_type
|
|
506
|
+
}
|
|
507
|
+
end
|
|
485
508
|
aggregator.record(
|
|
486
509
|
config_id: (cfg[:id] || cfg['id']).to_s,
|
|
487
510
|
config_key: key,
|
|
488
511
|
config_type: (cfg[:type] || cfg['type']).to_s,
|
|
489
512
|
conditional_value_index: result.rule_index,
|
|
490
513
|
weighted_value_index: result.weighted_value_index,
|
|
491
|
-
selected_value:
|
|
514
|
+
selected_value: selected_for_telemetry,
|
|
492
515
|
reason: result.wire_reason
|
|
493
516
|
)
|
|
494
517
|
end
|
|
@@ -545,6 +568,7 @@ module IntegrationTestHelpers
|
|
|
545
568
|
|
|
546
569
|
def self.evaluation_summary_post(event)
|
|
547
570
|
summaries = event.dig('summaries', 'summaries') || []
|
|
571
|
+
overrides = last_unwrapped_overrides || {}
|
|
548
572
|
rows = []
|
|
549
573
|
summaries.each do |summary|
|
|
550
574
|
type_label = TYPE_LABELS[summary['type'].to_s] || summary['type'].to_s.upcase
|
|
@@ -552,6 +576,15 @@ module IntegrationTestHelpers
|
|
|
552
576
|
counters.each do |counter|
|
|
553
577
|
selected = counter['selectedValue'] || {}
|
|
554
578
|
unwrapped, value_type = unwrap_selected(selected)
|
|
579
|
+
# When the resolver redacted this key (confidential / decryptWith),
|
|
580
|
+
# selected_value carries the redacted form on the wire but YAML's
|
|
581
|
+
# `value` / `value_type` should still reflect the runtime resolved
|
|
582
|
+
# plaintext. Restore from the side channel populated in
|
|
583
|
+
# record_one_eval.
|
|
584
|
+
if (override = overrides[summary['key']])
|
|
585
|
+
unwrapped = override[:unwrapped]
|
|
586
|
+
value_type = override[:value_type] if override[:value_type]
|
|
587
|
+
end
|
|
555
588
|
row = {
|
|
556
589
|
'key' => summary['key'],
|
|
557
590
|
'type' => type_label,
|
|
@@ -153,4 +153,18 @@ class TestTelemetry < Minitest::Test
|
|
|
153
153
|
IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, {}, contexts: {})
|
|
154
154
|
IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, nil, endpoint: "/api/v1/context-shapes")
|
|
155
155
|
end
|
|
156
|
+
|
|
157
|
+
# confidential plain string is redacted in selectedValue
|
|
158
|
+
def test_confidential_plain_string_is_redacted_in_selectedvalue
|
|
159
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
160
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["confidential.new.string"]}, contexts: {})
|
|
161
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "confidential.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****18aa7"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# confidential encrypted string is redacted using ciphertext hash
|
|
165
|
+
def test_confidential_encrypted_string_is_redacted_using_ciphertext_hash
|
|
166
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
167
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["a.secret.config"]}, contexts: {})
|
|
168
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "a.secret.config", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****936c9"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
169
|
+
end
|
|
156
170
|
end
|
|
@@ -129,4 +129,47 @@ class TestClientTelemetry < Minitest::Test
|
|
|
129
129
|
client.get('does.not.exist', 'fallback')
|
|
130
130
|
assert_nil summaries_agg.drain_event
|
|
131
131
|
end
|
|
132
|
+
|
|
133
|
+
# Regression: client used to hardcode weighted_value_index: nil, so split
|
|
134
|
+
# variants never reported as REASON_SPLIT in telemetry.
|
|
135
|
+
def test_weighted_value_records_split_reason_and_index
|
|
136
|
+
weighted_config = {
|
|
137
|
+
'id' => 'cid-weighted',
|
|
138
|
+
'key' => 'feature-flag.weighted',
|
|
139
|
+
'type' => 'feature_flag',
|
|
140
|
+
'valueType' => 'string',
|
|
141
|
+
'sendToClientSdk' => false,
|
|
142
|
+
'default' => {
|
|
143
|
+
'rules' => [
|
|
144
|
+
{
|
|
145
|
+
'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
146
|
+
'value' => {
|
|
147
|
+
'type' => 'weighted_values',
|
|
148
|
+
'value' => {
|
|
149
|
+
'hashByPropertyName' => 'user.key',
|
|
150
|
+
'weightedValues' => [
|
|
151
|
+
{ 'value' => { 'type' => 'string', 'value' => 'control' }, 'weight' => 1 },
|
|
152
|
+
{ 'value' => { 'type' => 'string', 'value' => 'variant' }, 'weight' => 99 }
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
},
|
|
159
|
+
'environment' => nil
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
store = Quonfig::ConfigStore.new
|
|
163
|
+
store.set('feature-flag.weighted', weighted_config)
|
|
164
|
+
|
|
165
|
+
client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
|
|
166
|
+
client.get('feature-flag.weighted', Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
|
|
167
|
+
|
|
168
|
+
counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
|
|
169
|
+
assert_equal Quonfig::EvalResult::REASON_SPLIT, counter['reason'],
|
|
170
|
+
'weighted variant evaluation must report REASON_SPLIT (3)'
|
|
171
|
+
refute_nil counter['weightedValueIndex'],
|
|
172
|
+
'weighted variant evaluation must report a weightedValueIndex'
|
|
173
|
+
assert_kind_of Integer, counter['weightedValueIndex']
|
|
174
|
+
end
|
|
132
175
|
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Coverage for the *_details getters on Quonfig::Client and Quonfig::BoundClient.
|
|
6
|
+
# These return Quonfig::EvaluationDetails carrying an OpenFeature-aligned
|
|
7
|
+
# +reason+ and (on the error path) error_code / error_message. They never raise.
|
|
8
|
+
class TestDetailsGetters < Minitest::Test
|
|
9
|
+
Details = Quonfig::EvaluationDetails
|
|
10
|
+
|
|
11
|
+
INTEGRATION_FIXTURE_DIR = File.expand_path(
|
|
12
|
+
'../../integration-test-data/data/integration-tests', __dir__
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# ---- Helpers --------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def fixture_client
|
|
18
|
+
skip "integration-test-data sibling missing at #{INTEGRATION_FIXTURE_DIR}" unless Dir.exist?(INTEGRATION_FIXTURE_DIR)
|
|
19
|
+
|
|
20
|
+
Quonfig::Client.new(
|
|
21
|
+
Quonfig::Options.new(
|
|
22
|
+
datadir: INTEGRATION_FIXTURE_DIR,
|
|
23
|
+
environment: 'Production',
|
|
24
|
+
enable_sse: false,
|
|
25
|
+
enable_polling: false
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# An ALWAYS_TRUE-only config: no targeting rules anywhere -> STATIC.
|
|
31
|
+
def make_static_config(key:, value:, type:)
|
|
32
|
+
{
|
|
33
|
+
'id' => '1',
|
|
34
|
+
'key' => key,
|
|
35
|
+
'type' => 'config',
|
|
36
|
+
'valueType' => type,
|
|
37
|
+
'sendToClientSdk' => false,
|
|
38
|
+
'default' => {
|
|
39
|
+
'rules' => [
|
|
40
|
+
{
|
|
41
|
+
'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
42
|
+
'value' => { 'type' => type, 'value' => value }
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
'environment' => nil
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def static_client(key:, value:, type:)
|
|
51
|
+
store = Quonfig::ConfigStore.new
|
|
52
|
+
store.set(key, make_static_config(key: key, value: value, type: type))
|
|
53
|
+
Quonfig::Client.new(Quonfig::Options.new, store: store)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ---- STATIC reason --------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def test_get_bool_details_static_reason_for_always_true_only_config
|
|
59
|
+
client = static_client(key: 'plain', value: true, type: 'bool')
|
|
60
|
+
details = client.get_bool_details('plain')
|
|
61
|
+
assert_kind_of Details, details
|
|
62
|
+
assert_equal true, details.value
|
|
63
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
64
|
+
assert_nil details.error_code
|
|
65
|
+
assert_nil details.error_message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_get_string_details_static_reason
|
|
69
|
+
client = static_client(key: 'plain.s', value: 'hi', type: 'string')
|
|
70
|
+
details = client.get_string_details('plain.s')
|
|
71
|
+
assert_equal 'hi', details.value
|
|
72
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_static_reason_via_integration_fixture_always_true
|
|
76
|
+
client = fixture_client
|
|
77
|
+
details = client.get_bool_details('always.true')
|
|
78
|
+
assert_equal true, details.value
|
|
79
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
80
|
+
ensure
|
|
81
|
+
client&.stop
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ---- TARGETING_MATCH reason ----------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_targeting_match_reason_via_integration_fixture
|
|
87
|
+
client = fixture_client
|
|
88
|
+
details = client.get_bool_details('of.targeting', context: { 'user' => { 'plan' => 'pro' } })
|
|
89
|
+
assert_equal true, details.value
|
|
90
|
+
assert_equal Details::REASON_TARGETING_MATCH, details.reason
|
|
91
|
+
ensure
|
|
92
|
+
client&.stop
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_targeting_match_falls_through_to_default_branch_still_targeting
|
|
96
|
+
# The of.targeting fixture has a property-match rule + an ALWAYS_TRUE
|
|
97
|
+
# fallback. Even when the fallback wins, the *config* has targeting rules,
|
|
98
|
+
# so wire_reason / of_reason returns TARGETING_MATCH.
|
|
99
|
+
client = fixture_client
|
|
100
|
+
details = client.get_bool_details('of.targeting', context: { 'user' => { 'plan' => 'free' } })
|
|
101
|
+
assert_equal false, details.value
|
|
102
|
+
assert_equal Details::REASON_TARGETING_MATCH, details.reason
|
|
103
|
+
ensure
|
|
104
|
+
client&.stop
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ---- SPLIT reason ---------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def test_split_reason_via_integration_fixture
|
|
110
|
+
client = fixture_client
|
|
111
|
+
# Pick a deterministic targetingKey so the outcome is reproducible.
|
|
112
|
+
details = client.get_string_details('of.weighted', context: { 'user' => { 'id' => 'user-123' } })
|
|
113
|
+
assert_equal Details::REASON_SPLIT, details.reason
|
|
114
|
+
assert_includes %w[variant-a variant-b], details.value
|
|
115
|
+
ensure
|
|
116
|
+
client&.stop
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ---- DEFAULT reason -------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def test_default_reason_when_no_rule_matches
|
|
122
|
+
# Config exists but no rule matches against the empty context.
|
|
123
|
+
config = {
|
|
124
|
+
'id' => '9',
|
|
125
|
+
'key' => 'no.match.here',
|
|
126
|
+
'type' => 'config',
|
|
127
|
+
'valueType' => 'string',
|
|
128
|
+
'sendToClientSdk' => false,
|
|
129
|
+
'default' => {
|
|
130
|
+
'rules' => [
|
|
131
|
+
{
|
|
132
|
+
'criteria' => [
|
|
133
|
+
{
|
|
134
|
+
'propertyName' => 'user.plan',
|
|
135
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
136
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['enterprise'] }
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
'value' => { 'type' => 'string', 'value' => 'gold' }
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
'environment' => nil
|
|
144
|
+
}
|
|
145
|
+
store = Quonfig::ConfigStore.new
|
|
146
|
+
store.set('no.match.here', config)
|
|
147
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: store)
|
|
148
|
+
|
|
149
|
+
details = client.get_string_details('no.match.here')
|
|
150
|
+
assert_nil details.value
|
|
151
|
+
assert_equal Details::REASON_DEFAULT, details.reason
|
|
152
|
+
assert_nil details.error_code
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ---- ERROR / FLAG_NOT_FOUND ----------------------------------------
|
|
156
|
+
|
|
157
|
+
def test_flag_not_found_returns_error_details
|
|
158
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
|
|
159
|
+
details = client.get_bool_details('does.not.exist')
|
|
160
|
+
assert_nil details.value
|
|
161
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
162
|
+
assert_equal Details::ERROR_FLAG_NOT_FOUND, details.error_code
|
|
163
|
+
refute_nil details.error_message
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def test_flag_not_found_for_string_details
|
|
167
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
|
|
168
|
+
details = client.get_string_details('nope')
|
|
169
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
170
|
+
assert_equal Details::ERROR_FLAG_NOT_FOUND, details.error_code
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ---- ERROR / TYPE_MISMATCH -----------------------------------------
|
|
174
|
+
|
|
175
|
+
def test_type_mismatch_for_int_when_value_is_string
|
|
176
|
+
client = static_client(key: 'wrong.type', value: 'oops', type: 'string')
|
|
177
|
+
details = client.get_int_details('wrong.type')
|
|
178
|
+
assert_nil details.value
|
|
179
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
180
|
+
assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
|
|
181
|
+
refute_nil details.error_message
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def test_type_mismatch_for_bool_when_value_is_string
|
|
185
|
+
client = static_client(key: 'bool.miss', value: 'true', type: 'string')
|
|
186
|
+
details = client.get_bool_details('bool.miss')
|
|
187
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
188
|
+
assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_type_mismatch_for_string_list_when_value_is_string
|
|
192
|
+
client = static_client(key: 'list.miss', value: 'a,b', type: 'string')
|
|
193
|
+
details = client.get_string_list_details('list.miss')
|
|
194
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
195
|
+
assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ---- json passes through unchanged ---------------------------------
|
|
199
|
+
|
|
200
|
+
def test_get_json_details_returns_hash_with_static_reason
|
|
201
|
+
payload = { 'a' => 1, 'b' => [1, 2] }
|
|
202
|
+
client = static_client(key: 'shape', value: payload, type: 'json')
|
|
203
|
+
details = client.get_json_details('shape')
|
|
204
|
+
assert_equal payload, details.value
|
|
205
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- Float / Int success path -------------------------------------
|
|
209
|
+
|
|
210
|
+
def test_get_int_details_static_reason
|
|
211
|
+
client = static_client(key: 'i', value: 42, type: 'int')
|
|
212
|
+
details = client.get_int_details('i')
|
|
213
|
+
assert_equal 42, details.value
|
|
214
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def test_get_float_details_static_reason
|
|
218
|
+
client = static_client(key: 'f', value: 3.14, type: 'double')
|
|
219
|
+
details = client.get_float_details('f')
|
|
220
|
+
assert_in_delta 3.14, details.value, 1e-9
|
|
221
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def test_get_string_list_details_static_reason
|
|
225
|
+
client = static_client(key: 'sl', value: %w[a b], type: 'string_list')
|
|
226
|
+
details = client.get_string_list_details('sl')
|
|
227
|
+
assert_equal %w[a b], details.value
|
|
228
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ---- BoundClient mirror --------------------------------------------
|
|
232
|
+
|
|
233
|
+
def test_bound_client_get_bool_details_passes_context
|
|
234
|
+
client = fixture_client
|
|
235
|
+
bound = client.in_context('user' => { 'plan' => 'pro' })
|
|
236
|
+
details = bound.get_bool_details('of.targeting')
|
|
237
|
+
assert_equal true, details.value
|
|
238
|
+
assert_equal Details::REASON_TARGETING_MATCH, details.reason
|
|
239
|
+
ensure
|
|
240
|
+
client&.stop
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
# qfg-pj0.5 — Dev-context injection. When enable_quonfig_user_context: true
|
|
9
|
+
# (or env var QUONFIG_DEV_CONTEXT=true), the SDK reads ~/.quonfig/tokens.json
|
|
10
|
+
# (written by `qfg login`) and merges {'quonfig-user' => {'email' => ...}}
|
|
11
|
+
# into globalContext. Customer-supplied keys win on collision.
|
|
12
|
+
#
|
|
13
|
+
# Mirror of sdk-node qfg-pj0.3 / sdk-go qfg-pj0.4.
|
|
14
|
+
class TestDevContext < Minitest::Test
|
|
15
|
+
def setup
|
|
16
|
+
super
|
|
17
|
+
@tmphome = Dir.mktmpdir('quonfig-dev-ctx-')
|
|
18
|
+
FileUtils.mkdir_p(File.join(@tmphome, '.quonfig'))
|
|
19
|
+
@old_home = ENV.fetch('HOME', nil)
|
|
20
|
+
ENV['HOME'] = @tmphome
|
|
21
|
+
ENV.delete('QUONFIG_DEV_CONTEXT')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def teardown
|
|
25
|
+
ENV['HOME'] = @old_home
|
|
26
|
+
ENV.delete('QUONFIG_DEV_CONTEXT')
|
|
27
|
+
FileUtils.remove_entry(@tmphome) if @tmphome && Dir.exist?(@tmphome)
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_tokens(payload)
|
|
32
|
+
File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), JSON.generate(payload))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def global_context_of(client)
|
|
36
|
+
client.instance_variable_get(:@global_context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 1. RED: injects quonfig-user.email when option enabled and file exists
|
|
40
|
+
def test_injects_quonfig_user_email_when_option_enabled
|
|
41
|
+
write_tokens(userEmail: 'bob@foo.com', accessToken: 'x', refreshToken: 'y', expiresAt: 0)
|
|
42
|
+
|
|
43
|
+
client = Quonfig::Client.new(
|
|
44
|
+
Quonfig::Options.new(enable_quonfig_user_context: true),
|
|
45
|
+
store: Quonfig::ConfigStore.new
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 2. RED: no-op when option disabled and no env var
|
|
52
|
+
def test_no_op_when_option_disabled
|
|
53
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
54
|
+
|
|
55
|
+
client = Quonfig::Client.new(
|
|
56
|
+
Quonfig::Options.new(global_context: { user: { 'plan' => 'pro' } }),
|
|
57
|
+
store: Quonfig::ConfigStore.new
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 3. RED: no-op when option enabled but file missing
|
|
64
|
+
def test_no_op_when_file_missing
|
|
65
|
+
# No tokens.json written.
|
|
66
|
+
client = Quonfig::Client.new(
|
|
67
|
+
Quonfig::Options.new(
|
|
68
|
+
enable_quonfig_user_context: true,
|
|
69
|
+
global_context: { user: { 'plan' => 'pro' } }
|
|
70
|
+
),
|
|
71
|
+
store: Quonfig::ConfigStore.new
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# 4. RED: no-op when file unparseable; warning emitted; init succeeds
|
|
78
|
+
def test_no_op_when_file_unparseable
|
|
79
|
+
File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), '{not valid json')
|
|
80
|
+
|
|
81
|
+
client = Quonfig::Client.new(
|
|
82
|
+
Quonfig::Options.new(enable_quonfig_user_context: true),
|
|
83
|
+
store: Quonfig::ConfigStore.new
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert_equal({}, global_context_of(client))
|
|
87
|
+
# The dev-context loader emits a warning to stderr that we want to verify.
|
|
88
|
+
assert_stderr(['quonfig'])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 5. RED: customer-supplied quonfig-user keys win on collision
|
|
92
|
+
def test_customer_global_context_wins
|
|
93
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
94
|
+
|
|
95
|
+
client = Quonfig::Client.new(
|
|
96
|
+
Quonfig::Options.new(
|
|
97
|
+
enable_quonfig_user_context: true,
|
|
98
|
+
global_context: { 'quonfig-user' => { 'email' => 'override@x.com' } }
|
|
99
|
+
),
|
|
100
|
+
store: Quonfig::ConfigStore.new
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert_equal({ 'quonfig-user' => { 'email' => 'override@x.com' } }, global_context_of(client))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 6. RED: env var QUONFIG_DEV_CONTEXT=true enables when option absent
|
|
107
|
+
def test_env_var_enables_when_option_absent
|
|
108
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
109
|
+
ENV['QUONFIG_DEV_CONTEXT'] = 'true'
|
|
110
|
+
|
|
111
|
+
client = Quonfig::Client.new(
|
|
112
|
+
Quonfig::Options.new,
|
|
113
|
+
store: Quonfig::ConfigStore.new
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 7. RED: integration — rule keyed on quonfig-user.email fires when injected
|
|
120
|
+
def test_attribute_reaches_eval_context
|
|
121
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
122
|
+
|
|
123
|
+
flag_config = {
|
|
124
|
+
'id' => 'cfg-flag',
|
|
125
|
+
'key' => 'my-flag',
|
|
126
|
+
'type' => 'feature_flag',
|
|
127
|
+
'valueType' => 'bool',
|
|
128
|
+
'sendToClientSdk' => false,
|
|
129
|
+
'default' => {
|
|
130
|
+
'rules' => [
|
|
131
|
+
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
'environment' => {
|
|
135
|
+
'id' => 'Production',
|
|
136
|
+
'rules' => [
|
|
137
|
+
{
|
|
138
|
+
'criteria' => [{
|
|
139
|
+
'propertyName' => 'quonfig-user.email',
|
|
140
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
141
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['bob@foo.com'] }
|
|
142
|
+
}],
|
|
143
|
+
'value' => { 'type' => 'bool', 'value' => true }
|
|
144
|
+
},
|
|
145
|
+
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
store = Quonfig::ConfigStore.new
|
|
151
|
+
store.set('my-flag', flag_config)
|
|
152
|
+
|
|
153
|
+
client = Quonfig::Client.new(
|
|
154
|
+
Quonfig::Options.new(
|
|
155
|
+
enable_quonfig_user_context: true,
|
|
156
|
+
environment: 'Production'
|
|
157
|
+
),
|
|
158
|
+
store: store
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert_equal true, client.get_bool('my-flag')
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -65,15 +65,17 @@ module Quonfig
|
|
|
65
65
|
refute(Options::DEFAULT_API_URLS.any? { |s| s.include?('reforge.com') })
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def
|
|
69
|
-
with_env('
|
|
70
|
-
assert_equal 'https://
|
|
68
|
+
def test_telemetry_destination_honors_quonfig_domain_env
|
|
69
|
+
with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
|
|
70
|
+
assert_equal 'https://telemetry.quonfig-staging.com',
|
|
71
71
|
Options.new.telemetry_destination
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def test_telemetry_destination_default
|
|
76
|
-
|
|
76
|
+
with_env('QUONFIG_DOMAIN', nil) do
|
|
77
|
+
assert_equal 'https://telemetry.quonfig.com', Options.new.telemetry_destination
|
|
78
|
+
end
|
|
77
79
|
end
|
|
78
80
|
end
|
|
79
81
|
end
|