quonfig 0.0.8 → 0.0.9
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/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/evaluator.rb +21 -2
- data/lib/quonfig/options.rb +4 -1
- 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 +6 -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_dev_context.rb +163 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2dfc7385d529233f3b0b7c5fc13dbe7d0d648851ea0815bde792465a1774319
|
|
4
|
+
data.tar.gz: f6c702ba4eccdebeda95b8f9341fc0bb077a2c141c9397cb7f3dccb1cbfa782a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 368273026e247c7c01df6e85f11865f94bb588d4c4e512bbc44de65dda0a7659b96a8bd321de6ea676934b9483b0dd99d0fe516d3b288cf93a709f401bbfd824
|
|
7
|
+
data.tar.gz: be80686fa0c8748c4e1c3f802cfc51c605b1a3e31ec7f9682885a139b5081a79d91d9155833143671bfffa75728bf1091116456defd90bd610d17a5133287e03
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.9
|
data/lib/quonfig/bound_client.rb
CHANGED
|
@@ -40,6 +40,32 @@ module Quonfig
|
|
|
40
40
|
@client.get_json(key, default: default, context: @context)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# ---- Details getters ----------------------------------------------
|
|
44
|
+
|
|
45
|
+
def get_bool_details(key)
|
|
46
|
+
@client.get_bool_details(key, context: @context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_string_details(key)
|
|
50
|
+
@client.get_string_details(key, context: @context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_int_details(key)
|
|
54
|
+
@client.get_int_details(key, context: @context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_float_details(key)
|
|
58
|
+
@client.get_float_details(key, context: @context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_string_list_details(key)
|
|
62
|
+
@client.get_string_list_details(key, context: @context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_json_details(key)
|
|
66
|
+
@client.get_json_details(key, context: @context)
|
|
67
|
+
end
|
|
68
|
+
|
|
43
69
|
def enabled?(feature_name)
|
|
44
70
|
@client.enabled?(feature_name, @context)
|
|
45
71
|
end
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -32,7 +32,7 @@ module Quonfig
|
|
|
32
32
|
else
|
|
33
33
|
Quonfig::Options.new(option_kwargs)
|
|
34
34
|
end
|
|
35
|
-
@global_context =
|
|
35
|
+
@global_context = build_initial_global_context(@options)
|
|
36
36
|
@instance_hash = SecureRandom.uuid
|
|
37
37
|
@store = store || Quonfig::ConfigStore.new
|
|
38
38
|
@evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
|
|
@@ -103,6 +103,38 @@ module Quonfig
|
|
|
103
103
|
typed_get(key, :json, default: default, context: context)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# ---- Details getters ----------------------------------------------
|
|
107
|
+
#
|
|
108
|
+
# Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
|
|
109
|
+
# carrying the OpenFeature-aligned resolution +reason+ ("STATIC",
|
|
110
|
+
# "TARGETING_MATCH", "SPLIT", "DEFAULT", or "ERROR") plus an
|
|
111
|
+
# +error_code+/+error_message+ on the error path. These methods never
|
|
112
|
+
# raise — exceptions are caught and rendered as ERROR details.
|
|
113
|
+
|
|
114
|
+
def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
|
|
115
|
+
evaluate_details(key, :bool, context)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def get_string_details(key, context: NO_DEFAULT_PROVIDED)
|
|
119
|
+
evaluate_details(key, String, context)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def get_int_details(key, context: NO_DEFAULT_PROVIDED)
|
|
123
|
+
evaluate_details(key, Integer, context)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def get_float_details(key, context: NO_DEFAULT_PROVIDED)
|
|
127
|
+
evaluate_details(key, Float, context)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
|
|
131
|
+
evaluate_details(key, :string_list, context)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get_json_details(key, context: NO_DEFAULT_PROVIDED)
|
|
135
|
+
evaluate_details(key, :json, context)
|
|
136
|
+
end
|
|
137
|
+
|
|
106
138
|
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
107
139
|
value = get(feature_name, false, jit_context)
|
|
108
140
|
value == true || value == 'true'
|
|
@@ -314,7 +346,7 @@ module Quonfig
|
|
|
314
346
|
config_key: config_field(config, :key),
|
|
315
347
|
config_type: config_field(config, :type),
|
|
316
348
|
conditional_value_index: result.rule_index,
|
|
317
|
-
weighted_value_index:
|
|
349
|
+
weighted_value_index: result.weighted_value_index,
|
|
318
350
|
selected_value: result.unwrapped_value,
|
|
319
351
|
reason: result.wire_reason
|
|
320
352
|
)
|
|
@@ -441,6 +473,21 @@ module Quonfig
|
|
|
441
473
|
merge_contexts(@global_context, jit)
|
|
442
474
|
end
|
|
443
475
|
|
|
476
|
+
# Combine the customer-supplied globalContext with the optional dev
|
|
477
|
+
# context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
|
|
478
|
+
# customer's so any explicit `quonfig-user` keys win on collision.
|
|
479
|
+
def build_initial_global_context(options)
|
|
480
|
+
customer = normalize_context(options.global_context)
|
|
481
|
+
enabled = options.enable_quonfig_user_context == true ||
|
|
482
|
+
ENV['QUONFIG_DEV_CONTEXT'] == 'true'
|
|
483
|
+
return customer unless enabled
|
|
484
|
+
|
|
485
|
+
dev = Quonfig::DevContext.load_quonfig_user_context
|
|
486
|
+
return customer if dev.nil?
|
|
487
|
+
|
|
488
|
+
merge_contexts(dev, customer)
|
|
489
|
+
end
|
|
490
|
+
|
|
444
491
|
def normalize_context(ctx)
|
|
445
492
|
return {} if ctx.nil?
|
|
446
493
|
return ctx if ctx.is_a?(Hash)
|
|
@@ -485,6 +532,61 @@ module Quonfig
|
|
|
485
532
|
nil
|
|
486
533
|
end
|
|
487
534
|
|
|
535
|
+
# Build a Quonfig::EvaluationDetails for +key+, evaluated against the
|
|
536
|
+
# caller's context, after coercing/checking +expected_type+. Never
|
|
537
|
+
# raises; all exceptions become ERROR details.
|
|
538
|
+
def evaluate_details(key, expected_type, context)
|
|
539
|
+
jit = context == NO_DEFAULT_PROVIDED ? nil : context
|
|
540
|
+
ctx = build_context(jit)
|
|
541
|
+
record_context_for_telemetry(ctx)
|
|
542
|
+
|
|
543
|
+
result =
|
|
544
|
+
begin
|
|
545
|
+
@resolver.get(key, ctx)
|
|
546
|
+
rescue Quonfig::Errors::MissingDefaultError => e
|
|
547
|
+
return Quonfig::EvaluationDetails.new(
|
|
548
|
+
value: nil,
|
|
549
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
550
|
+
error_code: Quonfig::EvaluationDetails::ERROR_FLAG_NOT_FOUND,
|
|
551
|
+
error_message: e.message
|
|
552
|
+
)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
if result.nil?
|
|
556
|
+
return Quonfig::EvaluationDetails.new(
|
|
557
|
+
value: nil,
|
|
558
|
+
reason: Quonfig::EvaluationDetails::REASON_DEFAULT
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
record_evaluation_for_telemetry(result)
|
|
563
|
+
|
|
564
|
+
raw_value = result.unwrapped_value
|
|
565
|
+
|
|
566
|
+
begin
|
|
567
|
+
coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
|
|
568
|
+
rescue Quonfig::Errors::TypeMismatchError => e
|
|
569
|
+
return Quonfig::EvaluationDetails.new(
|
|
570
|
+
value: nil,
|
|
571
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
572
|
+
error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
|
|
573
|
+
error_message: e.message
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
Quonfig::EvaluationDetails.new(
|
|
578
|
+
value: coerced,
|
|
579
|
+
reason: result.of_reason
|
|
580
|
+
)
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
Quonfig::EvaluationDetails.new(
|
|
583
|
+
value: nil,
|
|
584
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
585
|
+
error_code: Quonfig::EvaluationDetails::ERROR_GENERAL,
|
|
586
|
+
error_message: e.message
|
|
587
|
+
)
|
|
588
|
+
end
|
|
589
|
+
|
|
488
590
|
def typed_get(key, expected_type, default:, context:)
|
|
489
591
|
jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
|
|
490
592
|
value = get(key, default, jit)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Dev-only context loader. Reads ~/.quonfig/tokens.json (written by
|
|
7
|
+
# `qfg login`) and returns {'quonfig-user' => {'email' => ...}} when a
|
|
8
|
+
# userEmail is present. Returns nil when the file is missing, unreadable,
|
|
9
|
+
# or has no userEmail.
|
|
10
|
+
#
|
|
11
|
+
# The attribute is dev-only by construction: production servers do not
|
|
12
|
+
# run `qfg login` and therefore have no tokens file. Rules keyed on
|
|
13
|
+
# `quonfig-user.email` are dead code in prod.
|
|
14
|
+
module DevContext
|
|
15
|
+
TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
|
|
16
|
+
|
|
17
|
+
def self.load_quonfig_user_context
|
|
18
|
+
path = File.join(Dir.home, TOKENS_BASENAME)
|
|
19
|
+
return nil unless File.exist?(path)
|
|
20
|
+
|
|
21
|
+
raw = begin
|
|
22
|
+
File.read(path)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
warn "[quonfig] dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
|
|
25
|
+
return nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
parsed = begin
|
|
29
|
+
JSON.parse(raw)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
warn "[quonfig] dev-context: could not parse #{path} (#{e.message}); skipping injection"
|
|
32
|
+
return nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
email = parsed.is_a?(Hash) ? parsed['userEmail'] : nil
|
|
36
|
+
return nil unless email.is_a?(String) && !email.empty?
|
|
37
|
+
|
|
38
|
+
{ 'quonfig-user' => { 'email' => email } }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -416,14 +416,20 @@ module Quonfig
|
|
|
416
416
|
REASON_TARGETING_MATCH = 2
|
|
417
417
|
REASON_SPLIT = 3
|
|
418
418
|
|
|
419
|
-
attr_reader :value, :rule_index, :config
|
|
419
|
+
attr_reader :value, :rule_index, :config, :reportable_value
|
|
420
420
|
attr_accessor :weighted_value_index
|
|
421
421
|
|
|
422
|
-
def initialize(value:, rule_index:, config:, weighted_value_index: nil)
|
|
422
|
+
def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil)
|
|
423
423
|
@value = value
|
|
424
424
|
@rule_index = rule_index
|
|
425
425
|
@config = config
|
|
426
426
|
@weighted_value_index = weighted_value_index
|
|
427
|
+
# Telemetry-safe substitute for #unwrapped_value. Set by Resolver when
|
|
428
|
+
# the underlying Value was confidential / decryptWith, so callers
|
|
429
|
+
# (the eval-summary aggregator) never see the plaintext. Mirrors
|
|
430
|
+
# ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_value (the
|
|
431
|
+
# `*****<5-md5>` hash form).
|
|
432
|
+
@reportable_value = reportable_value
|
|
427
433
|
end
|
|
428
434
|
|
|
429
435
|
# Integer reason code for telemetry. Mirrors sdk-node's computeReason:
|
|
@@ -436,6 +442,19 @@ module Quonfig
|
|
|
436
442
|
REASON_TARGETING_MATCH
|
|
437
443
|
end
|
|
438
444
|
|
|
445
|
+
# OpenFeature-aligned reason string. Same classification logic as
|
|
446
|
+
# +wire_reason+ but as a public string the OF provider (and any
|
|
447
|
+
# third-party consumer of EvaluationDetails) can pass straight through.
|
|
448
|
+
#
|
|
449
|
+
# Returns one of: "STATIC", "TARGETING_MATCH", "SPLIT".
|
|
450
|
+
def of_reason
|
|
451
|
+
case wire_reason
|
|
452
|
+
when REASON_STATIC then EvaluationDetails::REASON_STATIC
|
|
453
|
+
when REASON_SPLIT then EvaluationDetails::REASON_SPLIT
|
|
454
|
+
else EvaluationDetails::REASON_TARGETING_MATCH
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
439
458
|
# True if any rule on the config (default or environment) has a
|
|
440
459
|
# non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
|
|
441
460
|
def self.targeting_rules?(config)
|
data/lib/quonfig/options.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Quonfig
|
|
|
21
21
|
attr_reader :poll_interval
|
|
22
22
|
attr_reader :global_context
|
|
23
23
|
attr_reader :logger_key
|
|
24
|
+
attr_reader :enable_quonfig_user_context
|
|
24
25
|
attr_accessor :is_fork
|
|
25
26
|
|
|
26
27
|
module ON_INITIALIZATION_FAILURE
|
|
@@ -74,7 +75,8 @@ module Quonfig
|
|
|
74
75
|
collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
|
|
75
76
|
allow_telemetry_in_local_mode: false,
|
|
76
77
|
global_context: {},
|
|
77
|
-
logger_key: nil
|
|
78
|
+
logger_key: nil,
|
|
79
|
+
enable_quonfig_user_context: false
|
|
78
80
|
)
|
|
79
81
|
@sdk_key = sdk_key
|
|
80
82
|
@environment = environment
|
|
@@ -94,6 +96,7 @@ module Quonfig
|
|
|
94
96
|
@is_fork = false
|
|
95
97
|
@global_context = global_context
|
|
96
98
|
@logger_key = logger_key
|
|
99
|
+
@enable_quonfig_user_context = enable_quonfig_user_context
|
|
97
100
|
|
|
98
101
|
# defaults that may be overridden by context_upload_mode
|
|
99
102
|
@collect_shapes = false
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
3
5
|
module Quonfig
|
|
4
6
|
# Public-API resolver: looks up a config by key in a ConfigStore and runs
|
|
5
7
|
# it through an Evaluator against a Context.
|
|
@@ -15,6 +17,11 @@ module Quonfig
|
|
|
15
17
|
# Quonfig::ConfigResolver — the two coexist during the JSON migration.
|
|
16
18
|
class Resolver
|
|
17
19
|
TRUE_VALUES = %w[true 1 t yes].freeze
|
|
20
|
+
# Prefix the eval-summary aggregator stamps onto redacted confidential
|
|
21
|
+
# values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
|
|
22
|
+
# ReforgeHQ/sdk-ruby/lib/reforge/config_value_unwrapper.rb so dashboards
|
|
23
|
+
# built against the predecessor wire format keep working.
|
|
24
|
+
CONFIDENTIAL_PREFIX = '*****'
|
|
18
25
|
|
|
19
26
|
attr_reader :store, :evaluator
|
|
20
27
|
attr_accessor :project_env_id
|
|
@@ -50,7 +57,8 @@ module Quonfig
|
|
|
50
57
|
value: resolved_value,
|
|
51
58
|
rule_index: eval_result.rule_index,
|
|
52
59
|
config: config,
|
|
53
|
-
weighted_value_index: weighted_index
|
|
60
|
+
weighted_value_index: weighted_index,
|
|
61
|
+
reportable_value: redacted_reportable_value(eval_result.value)
|
|
54
62
|
)
|
|
55
63
|
end
|
|
56
64
|
|
|
@@ -87,6 +95,26 @@ module Quonfig
|
|
|
87
95
|
|
|
88
96
|
private
|
|
89
97
|
|
|
98
|
+
# If +value+ is confidential or has a decryptWith key, return the
|
|
99
|
+
# `*****<5-hex>` redacted string the eval-summary telemetry aggregator
|
|
100
|
+
# should ship in place of the resolved plaintext. The hash is computed
|
|
101
|
+
# over the raw `value[:value]` (ciphertext when decryptWith is set,
|
|
102
|
+
# plaintext-as-stored when only `confidential: true`) — matches
|
|
103
|
+
# ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_wrapped_value
|
|
104
|
+
# (CONFIDENTIAL_PREFIX + first 5 chars of MD5).
|
|
105
|
+
def redacted_reportable_value(value)
|
|
106
|
+
return nil if value.nil?
|
|
107
|
+
|
|
108
|
+
confidential = vget(value, :confidential, 'confidential')
|
|
109
|
+
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
110
|
+
return nil unless confidential || (decrypt_with && !decrypt_with.to_s.empty?)
|
|
111
|
+
|
|
112
|
+
raw = vget(value, :value, 'value')
|
|
113
|
+
return nil if raw.nil?
|
|
114
|
+
|
|
115
|
+
"#{CONFIDENTIAL_PREFIX}#{Digest::MD5.hexdigest(raw.to_s)[0, 5]}"
|
|
116
|
+
end
|
|
117
|
+
|
|
90
118
|
def vget(hash, *keys)
|
|
91
119
|
return nil if hash.nil?
|
|
92
120
|
|
|
@@ -179,7 +207,7 @@ module Quonfig
|
|
|
179
207
|
begin
|
|
180
208
|
plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
|
|
181
209
|
rescue StandardError => e
|
|
182
|
-
raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message)
|
|
210
|
+
raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message), cause: e
|
|
183
211
|
end
|
|
184
212
|
|
|
185
213
|
{
|
|
@@ -153,11 +153,23 @@ module Quonfig
|
|
|
153
153
|
@at_exit_registered = true
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
# Wait this long for the background reporter thread to exit before
|
|
157
|
+
# giving up. Bounded so a thread blocked on a dead telemetry endpoint
|
|
158
|
+
# can't hang process exit.
|
|
159
|
+
AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS = 1.0
|
|
160
|
+
|
|
156
161
|
# Idempotent final drain. Safe to call after #stop has already
|
|
157
162
|
# drained: aggregators return nil when empty and #sync becomes a
|
|
158
|
-
# no-op.
|
|
163
|
+
# no-op. Bounded so a stuck reporter thread or dead telemetry
|
|
164
|
+
# endpoint can't hang process exit.
|
|
159
165
|
def final_drain_on_exit
|
|
160
166
|
@stopped.make_true
|
|
167
|
+
thread = @thread
|
|
168
|
+
@thread = nil
|
|
169
|
+
if thread&.alive?
|
|
170
|
+
thread.wakeup
|
|
171
|
+
thread.join(AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS)
|
|
172
|
+
end
|
|
161
173
|
sync
|
|
162
174
|
rescue StandardError => e
|
|
163
175
|
LOG.debug "[quonfig] at_exit telemetry drain failed: #{e.class}: #{e.message}"
|
data/lib/quonfig.rb
CHANGED
|
@@ -25,6 +25,7 @@ require 'quonfig/error'
|
|
|
25
25
|
require 'quonfig/duration'
|
|
26
26
|
require 'quonfig/reason'
|
|
27
27
|
require 'quonfig/evaluation'
|
|
28
|
+
require 'quonfig/evaluation_details'
|
|
28
29
|
require 'quonfig/encryption'
|
|
29
30
|
require 'quonfig/exponential_backoff'
|
|
30
31
|
require 'quonfig/periodic_sync'
|
|
@@ -39,6 +40,7 @@ require 'quonfig/errors/decryption_error'
|
|
|
39
40
|
require 'quonfig/errors/missing_environment_error'
|
|
40
41
|
require 'quonfig/errors/invalid_environment_error'
|
|
41
42
|
require 'quonfig/options'
|
|
43
|
+
require 'quonfig/dev_context'
|
|
42
44
|
require 'quonfig/rate_limit_cache'
|
|
43
45
|
require 'quonfig/weighted_value_resolver'
|
|
44
46
|
require 'quonfig/config_store'
|
data/quonfig.gemspec
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
|
3
3
|
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
|
|
4
4
|
# -*- encoding: utf-8 -*-
|
|
5
|
-
# stub: quonfig 0.0.
|
|
5
|
+
# stub: quonfig 0.0.9 ruby lib
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |s|
|
|
8
8
|
s.name = "quonfig".freeze
|
|
9
|
-
s.version = "0.0.
|
|
9
|
+
s.version = "0.0.9".freeze
|
|
10
10
|
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
12
12
|
s.require_paths = ["lib".freeze]
|
|
13
13
|
s.authors = ["Jeff Dwyer".freeze]
|
|
14
|
-
s.date = "2026-04-
|
|
14
|
+
s.date = "2026-04-27"
|
|
15
15
|
s.description = "Quonfig \u2014 feature flags and live config, stored as files in git.".freeze
|
|
16
16
|
s.email = "jeff@quonfig.com".freeze
|
|
17
17
|
s.extra_rdoc_files = [
|
|
@@ -54,6 +54,7 @@ Gem::Specification.new do |s|
|
|
|
54
54
|
"lib/quonfig/config_store.rb",
|
|
55
55
|
"lib/quonfig/context.rb",
|
|
56
56
|
"lib/quonfig/datadir.rb",
|
|
57
|
+
"lib/quonfig/dev_context.rb",
|
|
57
58
|
"lib/quonfig/duration.rb",
|
|
58
59
|
"lib/quonfig/encryption.rb",
|
|
59
60
|
"lib/quonfig/error.rb",
|
|
@@ -96,6 +97,7 @@ Gem::Specification.new do |s|
|
|
|
96
97
|
"test/fixtures/datafile.json",
|
|
97
98
|
"test/integration/test_context_precedence.rb",
|
|
98
99
|
"test/integration/test_datadir_environment.rb",
|
|
100
|
+
"test/integration/test_dev_overrides.rb",
|
|
99
101
|
"test/integration/test_enabled.rb",
|
|
100
102
|
"test/integration/test_enabled_with_contexts.rb",
|
|
101
103
|
"test/integration/test_get.rb",
|
|
@@ -119,6 +121,7 @@ Gem::Specification.new do |s|
|
|
|
119
121
|
"test/test_context_shape.rb",
|
|
120
122
|
"test/test_context_shape_aggregator.rb",
|
|
121
123
|
"test/test_datadir.rb",
|
|
124
|
+
"test/test_dev_context.rb",
|
|
122
125
|
"test/test_duration.rb",
|
|
123
126
|
"test/test_encryption.rb",
|
|
124
127
|
"test/test_evaluation_summaries_aggregator.rb",
|
|
@@ -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,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
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quonfig
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Dwyer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -213,6 +213,7 @@ files:
|
|
|
213
213
|
- lib/quonfig/config_store.rb
|
|
214
214
|
- lib/quonfig/context.rb
|
|
215
215
|
- lib/quonfig/datadir.rb
|
|
216
|
+
- lib/quonfig/dev_context.rb
|
|
216
217
|
- lib/quonfig/duration.rb
|
|
217
218
|
- lib/quonfig/encryption.rb
|
|
218
219
|
- lib/quonfig/error.rb
|
|
@@ -255,6 +256,7 @@ files:
|
|
|
255
256
|
- test/fixtures/datafile.json
|
|
256
257
|
- test/integration/test_context_precedence.rb
|
|
257
258
|
- test/integration/test_datadir_environment.rb
|
|
259
|
+
- test/integration/test_dev_overrides.rb
|
|
258
260
|
- test/integration/test_enabled.rb
|
|
259
261
|
- test/integration/test_enabled_with_contexts.rb
|
|
260
262
|
- test/integration/test_get.rb
|
|
@@ -278,6 +280,7 @@ files:
|
|
|
278
280
|
- test/test_context_shape.rb
|
|
279
281
|
- test/test_context_shape_aggregator.rb
|
|
280
282
|
- test/test_datadir.rb
|
|
283
|
+
- test/test_dev_context.rb
|
|
281
284
|
- test/test_duration.rb
|
|
282
285
|
- test/test_encryption.rb
|
|
283
286
|
- test/test_evaluation_summaries_aggregator.rb
|