diff --git a/lib/ldclient-rb/impl/evaluator_helpers.rb b/lib/ldclient-rb/impl/evaluator_helpers.rb index 842d734f..f7e0b57b 100644 --- a/lib/ldclient-rb/impl/evaluator_helpers.rb +++ b/lib/ldclient-rb/impl/evaluator_helpers.rb @@ -10,9 +10,9 @@ module EvaluatorHelpers # @param flag [LaunchDarkly::Impl::Model::FeatureFlag] # @param reason [LaunchDarkly::EvaluationReason] # - def self.evaluation_detail_for_off_variation(flag, reason, logger = nil) + def self.evaluation_detail_for_off_variation(flag, reason) index = flag.off_variation - index.nil? ? EvaluationDetail.new(nil, nil, reason) : evaluation_detail_for_variation(flag, index, reason, logger) + index.nil? ? EvaluationDetail.new(nil, nil, reason) : evaluation_detail_for_variation(flag, index, reason) end # @@ -20,11 +20,11 @@ def self.evaluation_detail_for_off_variation(flag, reason, logger = nil) # @param index [Integer] # @param reason [LaunchDarkly::EvaluationReason] # - def self.evaluation_detail_for_variation(flag, index, reason, logger = nil) + def self.evaluation_detail_for_variation(flag, index, reason) vars = flag.variations if index < 0 || index >= vars.length - logger.error("[LDClient] Data inconsistency in feature flag \"#{flag.key}\": invalid variation index") unless logger.nil? EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) + # This error condition has already been logged at the time we received the flag data - see model/feature_flag.rb else EvaluationDetail.new(vars[index], index, reason) end diff --git a/lib/ldclient-rb/impl/model/clause.rb b/lib/ldclient-rb/impl/model/clause.rb index 0227dc30..271606e4 100644 --- a/lib/ldclient-rb/impl/model/clause.rb +++ b/lib/ldclient-rb/impl/model/clause.rb @@ -1,3 +1,5 @@ +require "ldclient-rb/reference" + # See serialization.rb for implementation notes on the data model classes. @@ -5,14 +7,18 @@ module LaunchDarkly module Impl module Model class Clause - def initialize(data, logger) + def initialize(data, errors_out = nil) @data = data @context_kind = data[:contextKind] - @attribute = (@context_kind.nil? || @context_kind.empty?) ? Reference.create_literal(data[:attribute]) : Reference.create(data[:attribute]) - unless logger.nil? || @attribute.error.nil? - logger.error("[LDClient] Data inconsistency in feature flag: #{@attribute.error}") - end @op = data[:op].to_sym + if @op == :segmentMatch + @attribute = nil + else + @attribute = (@context_kind.nil? || @context_kind.empty?) ? Reference.create_literal(data[:attribute]) : Reference.create(data[:attribute]) + unless errors_out.nil? || @attribute.error.nil? + errors_out << "clause has invalid attribute: #{@attribute.error}" + end + end @values = data[:values] || [] @negate = !!data[:negate] end diff --git a/lib/ldclient-rb/impl/model/feature_flag.rb b/lib/ldclient-rb/impl/model/feature_flag.rb index 2f89905c..6c93877b 100644 --- a/lib/ldclient-rb/impl/model/feature_flag.rb +++ b/lib/ldclient-rb/impl/model/feature_flag.rb @@ -4,6 +4,14 @@ # See serialization.rb for implementation notes on the data model classes. +def check_variation_range(flag, errors_out, variation, description) + unless flag.nil? || errors_out.nil? || variation.nil? + if variation < 0 || variation >= flag.variations.length + errors_out << "#{description} has invalid variation index" + end + end +end + module LaunchDarkly module Impl module Model @@ -12,6 +20,7 @@ class FeatureFlag # @param logger [Logger|nil] def initialize(data, logger = nil) raise ArgumentError, "expected hash but got #{data.class}" unless data.is_a?(Hash) + errors = [] @data = data @key = data[:key] @version = data[:version] @@ -20,24 +29,30 @@ def initialize(data, logger = nil) @variations = data[:variations] || [] @on = !!data[:on] fallthrough = data[:fallthrough] || {} - @fallthrough = VariationOrRollout.new(fallthrough[:variation], fallthrough[:rollout]) + @fallthrough = VariationOrRollout.new(fallthrough[:variation], fallthrough[:rollout], self, errors, "fallthrough") @off_variation = data[:offVariation] + check_variation_range(self, errors, @off_variation, "off variation") @prerequisites = (data[:prerequisites] || []).map do |prereq_data| - Prerequisite.new(prereq_data, self, logger) + Prerequisite.new(prereq_data, self, errors) end @targets = (data[:targets] || []).map do |target_data| - Target.new(target_data, self, logger) + Target.new(target_data, self, errors) end @context_targets = (data[:contextTargets] || []).map do |target_data| - Target.new(target_data, self, logger) + Target.new(target_data, self, errors) end @rules = (data[:rules] || []).map.with_index do |rule_data, index| - FlagRule.new(rule_data, index, self, logger) + FlagRule.new(rule_data, index, self, errors) end @salt = data[:salt] - @off_result = EvaluatorHelpers.evaluation_detail_for_off_variation(self, EvaluationReason::off, logger) + @off_result = EvaluatorHelpers.evaluation_detail_for_off_variation(self, EvaluationReason::off) @fallthrough_results = Preprocessor.precompute_multi_variation_results(self, EvaluationReason::fallthrough(false), EvaluationReason::fallthrough(true)) + unless logger.nil? + errors.each do |message| + logger.error("[LDClient] Data inconsistency in feature flag \"#{@key}\": #{message}") + end + end end # @return [Hash] @@ -93,12 +108,13 @@ def to_json(*a) end class Prerequisite - def initialize(data, flag, logger) + def initialize(data, flag, errors_out = nil) @data = data @key = data[:key] @variation = data[:variation] @failure_result = EvaluatorHelpers.evaluation_detail_for_off_variation(flag, - EvaluationReason::prerequisite_failed(@key), logger) + EvaluationReason::prerequisite_failed(@key)) + check_variation_range(flag, errors_out, @variation, "prerequisite") end # @return [Hash] @@ -112,13 +128,14 @@ def initialize(data, flag, logger) end class Target - def initialize(data, flag, logger) + def initialize(data, flag, errors_out = nil) @kind = data[:contextKind] || LDContext::KIND_DEFAULT @data = data @values = Set.new(data[:values] || []) @variation = data[:variation] @match_result = EvaluatorHelpers.evaluation_detail_for_variation(flag, - data[:variation], EvaluationReason::target_match, logger) + data[:variation], EvaluationReason::target_match) + check_variation_range(flag, errors_out, @variation, "target") end # @return [String] @@ -134,12 +151,12 @@ def initialize(data, flag, logger) end class FlagRule - def initialize(data, rule_index, flag, logger) + def initialize(data, rule_index, flag, errors_out = nil) @data = data @clauses = (data[:clauses] || []).map do |clause_data| - Clause.new(clause_data, logger) + Clause.new(clause_data, errors_out) end - @variation_or_rollout = VariationOrRollout.new(data[:variation], data[:rollout]) + @variation_or_rollout = VariationOrRollout.new(data[:variation], data[:rollout], flag, errors_out, 'rule') rule_id = data[:id] match_reason = EvaluationReason::rule_match(rule_index, rule_id) match_reason_in_experiment = EvaluationReason::rule_match(rule_index, rule_id, true) @@ -157,9 +174,10 @@ def initialize(data, rule_index, flag, logger) end class VariationOrRollout - def initialize(variation, rollout_data) + def initialize(variation, rollout_data, flag = nil, errors_out = nil, description = nil) @variation = variation - @rollout = rollout_data.nil? ? nil : Rollout.new(rollout_data) + check_variation_range(flag, errors_out, variation, description) + @rollout = rollout_data.nil? ? nil : Rollout.new(rollout_data, flag, errors_out, description) end # @return [Integer|nil] @@ -169,9 +187,9 @@ def initialize(variation, rollout_data) end class Rollout - def initialize(data) + def initialize(data, flag = nil, errors_out = nil, description = nil) @context_kind = data[:contextKind] - @variations = (data[:variations] || []).map { |v| WeightedVariation.new(v) } + @variations = (data[:variations] || []).map { |v| WeightedVariation.new(v, flag, errors_out, description) } @bucket_by = data[:bucketBy] @kind = data[:kind] @is_experiment = @kind == "experiment" @@ -193,10 +211,11 @@ def initialize(data) end class WeightedVariation - def initialize(data) + def initialize(data, flag = nil, errors_out = nil, description = nil) @variation = data[:variation] @weight = data[:weight] @untracked = !!data[:untracked] + check_variation_range(flag, errors_out, @variation, description) end # @return [Integer] diff --git a/lib/ldclient-rb/impl/model/segment.rb b/lib/ldclient-rb/impl/model/segment.rb index d78036a7..6b8f3cb6 100644 --- a/lib/ldclient-rb/impl/model/segment.rb +++ b/lib/ldclient-rb/impl/model/segment.rb @@ -12,6 +12,7 @@ class Segment # @param logger [Logger|nil] def initialize(data, logger = nil) raise ArgumentError, "expected hash but got #{data.class}" unless data.is_a?(Hash) + errors = [] @data = data @key = data[:key] @version = data[:version] @@ -26,12 +27,17 @@ def initialize(data, logger = nil) SegmentTarget.new(target_data) end @rules = (data[:rules] || []).map do |rule_data| - SegmentRule.new(rule_data, logger) + SegmentRule.new(rule_data, errors) end @unbounded = !!data[:unbounded] @unbounded_context_kind = data[:unboundedContextKind] || LDContext::KIND_DEFAULT @generation = data[:generation] @salt = data[:salt] + unless logger.nil? + errors.each do |message| + logger.error("[LDClient] Data inconsistency in segment \"#{@key}\": #{message}") + end + end end # @return [Hash] @@ -98,10 +104,10 @@ def initialize(data) end class SegmentRule - def initialize(data, logger) + def initialize(data, errors_out = nil) @data = data @clauses = (data[:clauses] || []).map do |clause_data| - Clause.new(clause_data, logger) + Clause.new(clause_data, errors_out) end @weight = data[:weight] @bucket_by = data[:bucketBy] diff --git a/lib/ldclient-rb/impl/model/serialization.rb b/lib/ldclient-rb/impl/model/serialization.rb index 3bc3029d..088112b8 100644 --- a/lib/ldclient-rb/impl/model/serialization.rb +++ b/lib/ldclient-rb/impl/model/serialization.rb @@ -37,7 +37,7 @@ module Model # @param kind [Hash] normally either FEATURES or SEGMENTS # @param input [object] a JSON string or a parsed hash (or a data model object, in which case # we'll just return the original object) - # @param logger [Logger|nil] logs warnings if there are any data validation problems + # @param logger [Logger|nil] logs errors if there are any data validation problems # @return [Object] the flag or segment (or, for an unknown data kind, the data as a hash) def self.deserialize(kind, input, logger = nil) return nil if input.nil? diff --git a/spec/capturing_logger.rb b/spec/capturing_logger.rb new file mode 100644 index 00000000..75064939 --- /dev/null +++ b/spec/capturing_logger.rb @@ -0,0 +1,16 @@ +require "stringio" + +class CapturingLogger + def initialize + @output = StringIO.new + @logger = Logger.new(@output) + end + + def output + @output.string + end + + def method_missing(meth, *args, &block) + @logger.send(meth, *args, &block) + end +end diff --git a/spec/impl/model/model_validation_spec.rb b/spec/impl/model/model_validation_spec.rb new file mode 100644 index 00000000..665f17cd --- /dev/null +++ b/spec/impl/model/model_validation_spec.rb @@ -0,0 +1,275 @@ +require "ldclient-rb/impl/model/feature_flag" + +require "capturing_logger" +require "model_builders" +require "spec_helper" + + +def base_flag + FlagBuilder.new("flagkey").build.as_json +end + +def base_segment + FlagBuilder.new("flagkey").build.as_json +end + +def rules_with_clause(clause) + { + rules: [ + { variation: 0, clauses: [ clause ] }, + ], + } +end + +def segment_rules_with_clause(clause) + { + rules: [ + { clauses: [ clause ] }, + ], + } +end + + +module LaunchDarkly + module Impl + describe "flag model validation" do + describe "should not log errors for" do + [ + [ + "minimal valid flag", + base_flag, + ], + [ + "valid off variation", + base_flag.merge({variations: [true, false], offVariation: 1}), + ], + [ + "valid fallthrough variation", + base_flag.merge({variations: [true, false], fallthrough: {variation: 1}}), + ], + [ + "valid fallthrough rollout", + base_flag.merge({variations: [true, false], fallthrough: { + rollout: { + variations: [ + { variation: 0, weight: 100000 }, + ], + }, + }}), + ], + [ + "valid target variation", + base_flag.merge({variations: [true, false], targets: [ {variation: 1, values: []} ]}), + ], + [ + "valid rule variation", + base_flag.merge({variations: [true, false], rules: [ {variation: 1, clauses: []} ]}), + ], + [ + "valid attribute reference", + base_flag.merge(rules_with_clause({ + attribute: "name", op: "in", values: ["a"] + })), + ], + [ + "missing attribute reference when operator is segmentMatch", + base_flag.merge(rules_with_clause({ + op: "segmentMatch", values: ["a"] + })), + ], + [ + "empty attribute reference when operator is segmentMatch", + base_flag.merge(rules_with_clause({ + attribute: "", op: "segmentMatch", values: ["a"] + })), + ], + ].each do |params| + (name, flag_data_hash) = params + it(name) do + logger = CapturingLogger.new + LaunchDarkly::Impl::Model::FeatureFlag.new(flag_data_hash, logger) + expect(logger.output).to eq('') + end + end + end + + describe "should log errors for" do + [ + [ + "negative off variation", + base_flag.merge({variations: [true, false], offVariation: -1}), + "off variation has invalid variation index", + ], + [ + "too high off variation", + base_flag.merge({variations: [true, false], offVariation: 2}), + "off variation has invalid variation index", + ], + [ + "negative fallthrough variation", + base_flag.merge({variations: [true, false], fallthrough: {variation: -1}}), + "fallthrough has invalid variation index", + ], + [ + "too high fallthrough variation", + base_flag.merge({variations: [true, false], fallthrough: {variation: 2}}), + "fallthrough has invalid variation index", + ], + [ + "negative fallthrough rollout variation", + base_flag.merge({variations: [true, false], fallthrough: { + rollout: { + variations: [ + { variation: -1, weight: 100000 }, + ], + }, + }}), + "fallthrough has invalid variation index", + ], + [ + "fallthrough rollout too high variation", + base_flag.merge({variations: [true, false], fallthrough: { + rollout: { + variations: [ + { variation: 2, weight: 100000 }, + ], + }, + }}), + "fallthrough has invalid variation index", + ], + [ + "negative target variation", + base_flag.merge({ + variations: [true, false], + targets: [ + { variation: -1, values: [] }, + ], + }), + "target has invalid variation index", + ], + [ + "too high rule variation", + base_flag.merge({ + variations: [true, false], + targets: [ + { variation: 2, values: [] }, + ], + }), + "target has invalid variation index", + ], + [ + "negative rule variation", + base_flag.merge({ + variations: [true, false], + rules: [ + { variation: -1, clauses: [] }, + ], + }), + "rule has invalid variation index", + ], + [ + "too high rule variation", + base_flag.merge({ + variations: [true, false], + rules: [ + { variation: 2, clauses: [] }, + ], + }), + "rule has invalid variation index", + ], + [ + "negative rule rollout variation", + base_flag.merge({ + variations: [true, false], + rules: [ + { rollout: { variations: [ { variation: -1, weight: 10000 } ] }, clauses: [] }, + ], + }), + "rule has invalid variation index", + ], + [ + "too high rule variation", + base_flag.merge({ + variations: [true, false], + rules: [ + { rollout: { variations: [ { variation: 2, weight: 10000 } ] }, clauses: [] }, + ], + }), + "rule has invalid variation index", + ], + [ + "missing attribute reference", + base_flag.merge(rules_with_clause({ op: "in", values: ["a"] })), + "clause has invalid attribute: empty reference", + ], + [ + "empty attribute reference", + base_flag.merge(rules_with_clause({ attribute: "", op: "in", values: ["a"] })), + "clause has invalid attribute: empty reference", + ], + [ + "invalid attribute reference", + base_flag.merge(rules_with_clause({ contextKind: "user", attribute: "//", op: "in", values: ["a"] })), + "clause has invalid attribute: double or trailing slash", + ], + ].each do |params| + (name, flag_data_hash, expected_error) = params + it(name) do + logger = CapturingLogger.new + LaunchDarkly::Impl::Model::FeatureFlag.new(flag_data_hash, logger) + expect(logger.output).to match(Regexp.escape( + "Data inconsistency in feature flag \"#{flag_data_hash[:key]}\": #{expected_error}" + )) + end + end + end + end + + describe "segment model validation" do + describe "should not log errors for" do + [ + [ + "minimal valid segment", + base_segment, + ], + ].each do |params| + (name, segment_data_hash) = params + it(name) do + logger = CapturingLogger.new + LaunchDarkly::Impl::Model::Segment.new(segment_data_hash, logger) + expect(logger.output).to eq('') + end + end + end + end + + describe "should log errors for" do + [ + [ + "missing attribute reference", + base_segment.merge(segment_rules_with_clause({ op: "in", values: ["a"] })), + "clause has invalid attribute: empty reference", + ], + [ + "empty attribute reference", + base_segment.merge(segment_rules_with_clause({ attribute: "", op: "in", values: ["a"] })), + "clause has invalid attribute: empty reference", + ], + [ + "invalid attribute reference", + base_segment.merge(segment_rules_with_clause({ contextKind: "user", attribute: "//", op: "in", values: ["a"] })), + "clause has invalid attribute: double or trailing slash", + ], + ].each do |params| + (name, segment_data_hash, expected_error) = params + it(name) do + logger = CapturingLogger.new + LaunchDarkly::Impl::Model::Segment.new(segment_data_hash, logger) + expect(logger.output).to match(Regexp.escape( + "Data inconsistency in segment \"#{segment_data_hash[:key]}\": #{expected_error}" + )) + end + end + end + end +end