diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy index ca80ca47f8b..127da0ae3ac 100644 --- a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy @@ -206,8 +206,12 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { private static Map buildLoggedAllocations(final Map config) { final logged = [:] (config.flags as Map).each { flag, definition -> - (definition.allocations ?: []).each { allocation -> - logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true + if (definition.allocations instanceof List) { + definition.allocations.each { allocation -> + if (allocation instanceof Map) { + logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true + } + } } } return logged diff --git a/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data index b42c4a104ea..aaa97e44bb1 160000 --- a/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data +++ b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data @@ -1 +1 @@ -Subproject commit b42c4a104ea70695c8fc3516780951f7434b7906 +Subproject commit aaa97e44bb19c9cee7ae079995ddbcc864c6b702 diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index fa394417c8a..e74074a640a 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -9,6 +9,7 @@ import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import datadog.trace.api.featureflag.ufc.v1.Shard; @@ -171,6 +172,20 @@ public ProviderEvaluation evaluate( final Flag flag = config.flags.get(key); if (flag == null) { + if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { + if ("invalid_semver_comparand".equals(config.invalidFlags.get(key))) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "invalid configuration for flag " + key, + observeFullEvaluationData); + } + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); } @@ -361,6 +376,18 @@ private static boolean evaluateCondition( return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); case LT: return compareNumber(attributeValue, condition.value, (a, b) -> a < b); + case SEMVER_EQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); + case SEMVER_NEQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); + case SEMVER_LT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); + case SEMVER_LTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); + case SEMVER_GT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); + case SEMVER_GTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); default: return false; } @@ -369,10 +396,20 @@ private static boolean evaluateCondition( private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), // which maps it to ErrorCode.PARSE_ERROR. - final Pattern pattern = Pattern.compile(String.valueOf(conditionValue)); + final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); return pattern.matcher(String.valueOf(attributeValue)).find(); } + private static String normalizeRegex(final String regex) { + return regex + .replace("[:alnum:]", "\\p{Alnum}") + .replace("[:alpha:]", "\\p{Alpha}") + .replace("[:digit:]", "\\p{Digit}") + .replace("[:lower:]", "\\p{Lower}") + .replace("[:upper:]", "\\p{Upper}") + .replace("[:space:]", "\\p{Space}"); + } + private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { if (!(conditionValue instanceof Iterable)) { return false; @@ -404,6 +441,25 @@ private static boolean compareNumber( return comparator.compare(a, b); } + /** + * Evaluates a semantic version comparison operator. The attribute value must be a string that is + * a valid semantic version, and the comparand must have been pre-parsed during configuration + * validation. If either is missing or invalid, the condition does not match. + */ + private static boolean evaluateSemverCondition( + final Object attributeValue, + final ParsedSemver comparand, + final SemverComparator comparator) { + if (!(attributeValue instanceof String) || comparand == null) { + return false; + } + final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); + if (parsedAttribute == null) { + return false; + } + return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); + } + private static boolean matchesShard(final Shard shard, final String targetingKey) { final int assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); for (final ShardRange range : shard.ranges) { @@ -513,7 +569,9 @@ private static ProviderEvaluation resolveVariant( .reason( !isEmpty(allocation.rules) ? Reason.TARGETING_MATCH.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) .variant(variant.key) .flagMetadata(metadataBuilder.build()) .build(); @@ -911,6 +969,11 @@ private interface NumberComparator { boolean compare(double a, double b); } + @FunctionalInterface + private interface SemverComparator { + boolean compare(int ordering); + } + private static class FlattenEntry { private final String key; private final Value value; diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index 06d3d42af24..aeddda0bfd2 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -30,6 +30,7 @@ import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import datadog.trace.api.featureflag.ufc.v1.Split; @@ -67,13 +68,16 @@ public class DDEvaluatorTest { private static final String CANONICAL_FIXTURE_PATH = "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = new Moshi.Builder().add(Date.class, new DateAdapter()).build(); + private static final Moshi MOSHI = + new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); private static final JsonAdapter CONFIG_ADAPTER = MOSHI.adapter(ServerConfiguration.class); private static final Type FIXTURE_LIST_TYPE = Types.newParameterizedType(List.class, FixtureCase.class); private static final JsonAdapter> FIXTURE_LIST_ADAPTER = MOSHI.adapter(FIXTURE_LIST_TYPE); + private static final ThreadLocal> INVALID_FLAGS_HOLDER = + ThreadLocal.withInitial(HashMap::new); @Test public void testInitializeSignalsApplicationProviderActivation() throws Exception { @@ -484,6 +488,134 @@ public void testAllocationWindowHonorsMicrosecondPrecision() { assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); } + // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- + + private static Flag semverFlag(final ConditionOperator operator, final String comparand) { + final ParsedSemver parsed = ParsedSemver.parse(comparand); + final ConditionConfiguration condition = + new ConditionConfiguration(operator, "version", comparand); + condition.semverComparand = parsed; + final Rule rule = new Rule(singletonList(condition)); + final Split split = new Split(emptyList(), "on", null, null); + final Allocation allocation = + Allocation.fromInstants( + "targeted", singletonList(rule), null, null, singletonList(split), false); + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", true)); + return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); + } + + private static EvaluationContext semverContext(final Object version) { + final Map attributes = new HashMap<>(); + if (version != null) { + attributes.put("version", version); + } + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("subject"); + return context; + } + + static Arguments[] semverConditionTestCases() { + return new Arguments[] { + // Equal + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), + // Not equal + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), + // Less than + Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), + // Less than or equal + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), + // Greater than + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), + // Greater than or equal + Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), + // Prerelease ordering + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), + // Build metadata is ignored + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), + // Invalid attribute does not match + Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), + // Non-string attribute does not match + Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), + }; + } + + @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") + @MethodSource("semverConditionTestCases") + public void testEvaluateSemverCondition( + final ConditionOperator operator, + final Object attribute, + final String comparand, + final boolean wantMatch) { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(operator, comparand)); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); + + if (wantMatch) { + assertThat(details.getValue(), equalTo(true)); + assertThat(details.getReason(), equalTo("TARGETING_MATCH")); + } else { + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + } + + @Test + public void testEvaluateSemverConditionMissingAttribute() { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + + @Test + public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { + // A flag with an invalid semver comparand is dropped during parsing. + // The evaluator should return PARSE_ERROR when the flag is queried. + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("invalid-semver", "invalid_semver_comparand"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); + config.invalidFlags = invalidFlags; + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + private static Arguments[] flatteningTestCases() { final List arguments = new ArrayList<>(); arguments.add(Arguments.of(emptyMap(), emptyMap())); @@ -721,7 +853,87 @@ private static ProviderEvaluation evaluate( } private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - return CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); + INVALID_FLAGS_HOLDER.get().clear(); + try { + final ServerConfiguration config = + CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); + final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); + if (!invalidFlags.isEmpty()) { + config.invalidFlags = invalidFlags; + } + validateAndCacheSemverComparands(config); + return config; + } finally { + INVALID_FLAGS_HOLDER.get().clear(); + } + } + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags + * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching + * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. + */ + private static void validateAndCacheSemverComparands(final ServerConfiguration config) { + if (config.flags == null) { + return; + } + final Map invalidFlags = + config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); + final Map flagsToRemove = new HashMap<>(); + for (final Map.Entry entry : config.flags.entrySet()) { + final String flagKey = entry.getKey(); + final Flag flag = entry.getValue(); + if (flag.allocations == null) { + continue; + } + boolean invalid = false; + for (final Allocation allocation : flag.allocations) { + if (allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition.operator == null) { + continue; + } + switch (condition.operator) { + case SEMVER_EQ: + case SEMVER_NEQ: + case SEMVER_LT: + case SEMVER_LTE: + case SEMVER_GT: + case SEMVER_GTE: + if (!(condition.value instanceof String)) { + invalid = true; + break; + } + final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); + if (parsed == null) { + invalid = true; + break; + } + condition.semverComparand = parsed; + break; + default: + break; + } + } + } + } + if (invalid) { + flagsToRemove.put(flagKey, flag); + invalidFlags.put(flagKey, "invalid_semver_comparand"); + } + } + for (final String flagKey : flagsToRemove.keySet()) { + config.flags.remove(flagKey); + } + if (!invalidFlags.isEmpty()) { + config.invalidFlags = invalidFlags; + } } private static List canonicalTestCases() throws IOException { @@ -836,6 +1048,73 @@ private static final class FixtureResult { Map flagMetadata = emptyMap(); } + /** Reads the flags map with per-flag failure isolation, matching the production parser. */ + private static final class FlagMapAdapter extends JsonAdapter> { + private static final Type FLAGS_TYPE = + Types.newParameterizedType(Map.class, String.class, Flag.class); + + private static final JsonAdapter.Factory FACTORY = + (type, annotations, moshi) -> { + if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { + return null; + } + return new FlagMapAdapter(moshi.adapter(Flag.class)); + }; + + private final JsonAdapter flagAdapter; + + private FlagMapAdapter(final JsonAdapter flagAdapter) { + this.flagAdapter = flagAdapter; + } + + @Override + public Map fromJson(final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + final Map flags = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String flagKey = reader.nextName(); + final Object rawFlag = reader.readJsonValue(); + try { + final Flag flag = flagAdapter.fromJsonValue(rawFlag); + if (flag != null) { + validateFlag(flagKey, flag); + flags.put(flagKey, flag); + } + } catch (JsonDataException | IllegalArgumentException ignored) { + INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); + // A malformed flag must not prevent valid flags in the same configuration from loading. + } + } + reader.endObject(); + return flags; + } + + private static void validateFlag(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; + } + for (final Allocation allocation : flag.allocations) { + if (allocation == null || allocation.splits == null) { + continue; + } + for (final Split split : allocation.splits) { + if (split != null && split.shards == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains a split with missing shards"); + } + } + } + } + + @Override + public void toJson(final JsonWriter writer, final Map value) { + throw new UnsupportedOperationException("Reading only adapter"); + } + } + private static final class DateAdapter extends JsonAdapter { @Override public Date fromJson(final JsonReader reader) throws IOException { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java index 163df03867a..ae693674acc 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java @@ -5,6 +5,10 @@ public class ConditionConfiguration { public final String attribute; public final Object value; + // The validated, parsed SemVer condition value. Set during configuration preprocessing + // (not from JSON) when the operator is a SEMVER_* operator. + public transient ParsedSemver semverComparand; + public ConditionConfiguration( final ConditionOperator operator, final String attribute, final Object value) { this.operator = operator; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java index 088f934b126..1e864da2b6a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java @@ -9,5 +9,11 @@ public enum ConditionOperator { NOT_MATCHES, ONE_OF, NOT_ONE_OF, - IS_NULL + IS_NULL, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_LT, + SEMVER_LTE, + SEMVER_GT, + SEMVER_GTE } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java new file mode 100644 index 00000000000..c2ae6bef121 --- /dev/null +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java @@ -0,0 +1,315 @@ +package datadog.trace.api.featureflag.ufc.v1; + +/** + * ParsedSemver is the language-neutral representation of the SemVer subset used by FFE. Owning this + * parser and comparator keeps behavior consistent across SDKs and lets configuration preprocessing + * cache comparands instead of reparsing them during every evaluation. + * + *

Core identifiers (major, minor, patch) are limited to unsigned 64-bit integers, stored in + * {@code long} fields and compared with unsigned semantics. Numeric prerelease identifiers may be + * arbitrarily large and are compared by length-then-lexicographic order. Build metadata is + * validated during parsing but not retained because it does not affect SemVer precedence. + */ +public final class ParsedSemver { + + /** Sentinel returned by {@link #parse(String)} when the input is not a valid semantic version. */ + public static final ParsedSemver INVALID = null; + + private final long major; // unsigned + private final long minor; // unsigned + private final long patch; // unsigned + private final String prerelease; + + ParsedSemver(final long major, final long minor, final long patch, final String prerelease) { + this.major = major; + this.minor = minor; + this.patch = patch; + this.prerelease = prerelease; + } + + /** + * Parses a semantic version string using the syntax supported by FFE. + * + * @param version the version string to parse + * @return a {@link ParsedSemver} instance, or {@code null} if the input is not a valid semantic + * version + */ + public static ParsedSemver parse(final String version) { + // Parse major + final long[] majorResult = parseCoreIdentifier(version, 0); + if (majorResult == null) { + return INVALID; + } + final long major = majorResult[0]; + int next = (int) majorResult[1]; + if (next >= version.length() || version.charAt(next) != '.') { + return INVALID; + } + + // Parse minor + final long[] minorResult = parseCoreIdentifier(version, next + 1); + if (minorResult == null) { + return INVALID; + } + final long minor = minorResult[0]; + next = (int) minorResult[1]; + if (next >= version.length() || version.charAt(next) != '.') { + return INVALID; + } + + // Parse patch + final long[] patchResult = parseCoreIdentifier(version, next + 1); + if (patchResult == null) { + return INVALID; + } + final long patch = patchResult[0]; + next = (int) patchResult[1]; + + final ParsedSemver parsed = new ParsedSemver(major, minor, patch, ""); + if (next == version.length()) { + return parsed; + } + + // Parse prerelease and/or build metadata + String remainder = version.substring(next); + String prerelease = ""; + + if (remainder.charAt(0) == '-') { + remainder = remainder.substring(1); + final int buildStart = remainder.indexOf('+'); + if (buildStart == -1) { + if (!validSemverIdentifiers(remainder, false)) { + return INVALID; + } + prerelease = remainder; + return new ParsedSemver(major, minor, patch, prerelease); + } + + prerelease = remainder.substring(0, buildStart); + if (!validSemverIdentifiers(prerelease, false)) { + return INVALID; + } + remainder = remainder.substring(buildStart + 1); + } else if (remainder.charAt(0) == '+') { + remainder = remainder.substring(1); + } else { + return INVALID; + } + + if (!validSemverIdentifiers(remainder, true)) { + return INVALID; + } + return new ParsedSemver(major, minor, patch, prerelease); + } + + /** + * Compares two parsed semantic versions by precedence. Build metadata is intentionally ignored. + * + * @param left the left operand + * @param right the right operand + * @return negative if {@code left} precedes {@code right}, positive if {@code left} follows + * {@code right}, zero if they have equal precedence + */ + public static int compare(final ParsedSemver left, final ParsedSemver right) { + int cmp = Long.compareUnsigned(left.major, right.major); + if (cmp != 0) { + return cmp; + } + cmp = Long.compareUnsigned(left.minor, right.minor); + if (cmp != 0) { + return cmp; + } + cmp = Long.compareUnsigned(left.patch, right.patch); + if (cmp != 0) { + return cmp; + } + return comparePrerelease(left.prerelease, right.prerelease); + } + + /** + * Parses a core version identifier (major, minor, or patch). Enforces the unsigned 64-bit bound + * without accepting leading zeros (except for the value zero itself). + * + * @return a two-element array {@code {value, nextIndex}}, or {@code null} on failure + */ + private static long[] parseCoreIdentifier(final String version, final int start) { + if (start >= version.length() || !isASCIIDigit(version.charAt(start))) { + return null; + } + if (version.charAt(start) == '0') { + return new long[] {0, start + 1}; + } + + int end = start; + while (end < version.length() && isASCIIDigit(version.charAt(end))) { + end++; + } + try { + final long value = Long.parseUnsignedLong(version.substring(start, end)); + return new long[] {value, end}; + } catch (final NumberFormatException e) { + return null; // overflow + } + } + + /** + * Validates dot-separated identifiers. Permits leading zeros for build metadata only; numeric + * prerelease identifiers reject them. + */ + private static boolean validSemverIdentifiers( + final String value, final boolean allowLeadingZeros) { + int identifierStart = 0; + boolean identifierNumeric = true; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == '.') { + if (i == identifierStart) { + return false; // empty identifier + } + if (!allowLeadingZeros + && identifierNumeric + && i - identifierStart > 1 + && value.charAt(identifierStart) == '0') { + return false; // leading zero in numeric identifier + } + identifierStart = i + 1; + identifierNumeric = true; + continue; + } + + final char c = value.charAt(i); + if (!isASCIIAlphanumeric(c) && c != '-') { + return false; + } + if (!isASCIIDigit(c)) { + identifierNumeric = false; + } + } + return true; + } + + private static int comparePrerelease(final String left, final String right) { + if (left.equals(right)) { + return 0; + } + if (left.isEmpty()) { + return 1; // release > prerelease + } + if (right.isEmpty()) { + return -1; // prerelease < release + } + + int leftPos = 0; + int rightPos = 0; + while (true) { + // Extract next identifier from each side + int leftDot = left.indexOf('.', leftPos); + int rightDot = right.indexOf('.', rightPos); + final String leftIdentifier = + leftDot == -1 ? left.substring(leftPos) : left.substring(leftPos, leftDot); + final String rightIdentifier = + rightDot == -1 ? right.substring(rightPos) : right.substring(rightPos, rightDot); + + final int ordering = compareIdentifier(leftIdentifier, rightIdentifier); + if (ordering != 0) { + return ordering; + } + + if (leftDot == -1) { + if (rightDot == -1) { + return 0; + } + return -1; // left has fewer identifiers + } + if (rightDot == -1) { + return 1; // right has fewer identifiers + } + leftPos = leftDot + 1; + rightPos = rightDot + 1; + } + } + + private static int compareIdentifier(final String left, final String right) { + final boolean leftNumeric = isNumericIdentifier(left); + final boolean rightNumeric = isNumericIdentifier(right); + + if (leftNumeric && rightNumeric) { + // Numeric identifiers: compare by length first (longer = larger), then lexicographically + if (left.length() < right.length()) { + return -1; + } + if (left.length() > right.length()) { + return 1; + } + return left.compareTo(right); + } else if (leftNumeric) { + return -1; // numeric < alphanumeric + } else if (rightNumeric) { + return 1; // alphanumeric > numeric + } + return left.compareTo(right); + } + + private static boolean isNumericIdentifier(final String value) { + for (int i = 0; i < value.length(); i++) { + if (!isASCIIDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isASCIIDigit(final char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isASCIIAlphanumeric(final char c) { + return isASCIIDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + // --- Accessors for testing --- + + long getMajor() { + return major; + } + + long getMinor() { + return minor; + } + + long getPatch() { + return patch; + } + + String getPrerelease() { + return prerelease; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ParsedSemver)) { + return false; + } + final ParsedSemver that = (ParsedSemver) o; + return major == that.major + && minor == that.minor + && patch == that.patch + && prerelease.equals(that.prerelease); + } + + @Override + public int hashCode() { + int result = Long.hashCode(major); + result = 31 * result + Long.hashCode(minor); + result = 31 * result + Long.hashCode(patch); + result = 31 * result + prerelease.hashCode(); + return result; + } + + @Override + public String toString() { + return major + "." + minor + "." + patch + (prerelease.isEmpty() ? "" : "-" + prerelease); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java index caaa85a611f..60f39fc666f 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java @@ -1,5 +1,6 @@ package datadog.trace.api.featureflag.ufc.v1; +import java.util.Collections; import java.util.Map; public class ServerConfiguration { @@ -14,6 +15,10 @@ public class ServerConfiguration { public final Environment environment; public final Map flags; + // Flags that could not be parsed or validated. The key is the flag key; the value is the error + // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. + public transient Map invalidFlags = Collections.emptyMap(); + public ServerConfiguration( final String createdAt, final String format, diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java new file mode 100644 index 00000000000..5854e42cb73 --- /dev/null +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java @@ -0,0 +1,161 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ParsedSemverTest { + + // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- + + static Stream validVersions() { + return Stream.of( + Arguments.of("0.0.0", 0L, 0L, 0L, ""), + Arguments.of( + "18446744073709551615.18446744073709551615.18446744073709551615", + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + ""), + Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), + Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), + Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), + Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); + } + + @ParameterizedTest(name = "valid: {0}") + @MethodSource("validVersions") + void testParseValid( + final String version, + final long major, + final long minor, + final long patch, + final String prerelease) { + final ParsedSemver parsed = ParsedSemver.parse(version); + assertTrue(parsed != null, "expected " + version + " to parse"); + assertEquals(major, parsed.getMajor()); + assertEquals(minor, parsed.getMinor()); + assertEquals(patch, parsed.getPatch()); + assertEquals(prerelease, parsed.getPrerelease()); + } + + static Stream invalidVersions() { + return Stream.of( + "", + "x", + "1", + "1.2", + "1.2.3.4", + "v1.2.3", + "01.2.3", + "1.02.3", + "1.2.03", + "18446744073709551616.0.0", + "0.18446744073709551616.0", + "0.0.18446744073709551616", + "1.2.3-", + "1.2.3+", + "1.2.3-alpha..1", + "1.2.3+build..1", + "1.2.3-01", + "1.2.3-alpha_1", + "1.2.3-alpha+build+other", + "1.2.3-α", + " 1.2.3", + "1.2.3 "); + } + + @ParameterizedTest(name = "invalid: {0}") + @MethodSource("invalidVersions") + void testParseInvalid(final String version) { + assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); + } + + // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- + + /** The canonical SemVer precedence ordering from the spec. */ + private static final String[] ORDERED_VERSIONS = { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + }; + + @Test + void testCompareSemverOrdering() { + for (int i = 0; i < ORDERED_VERSIONS.length; i++) { + final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); + assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); + for (int j = 0; j < ORDERED_VERSIONS.length; j++) { + final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); + assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); + final int ordering = ParsedSemver.compare(left, right); + if (i < j) { + assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); + } else if (i > j) { + assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); + } else { + assertEquals(0, ordering); + } + } + } + } + + @Test + void testCompareSemverArbitrarilyLargeNumericPrerelease() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testValueObjectMethods() { + final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver release = ParsedSemver.parse("1.2.3"); + assertTrue(value != null); + assertTrue(equalValue != null); + assertTrue(release != null); + assertTrue(value.equals(value)); + assertTrue(value.equals(equalValue)); + assertFalse(value.equals(null)); + assertFalse(value.equals("1.2.3-alpha")); + assertEquals(value.hashCode(), equalValue.hashCode()); + assertEquals("1.2.3-alpha", value.toString()); + assertEquals("1.2.3", release.toString()); + } + + @Test + void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testCompareSemverBuildMetadataIsIgnored() { + final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); + assertTrue(right != null); + assertEquals(0, ParsedSemver.compare(left, right)); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index f89ac1ddc38..8044160e459 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java @@ -8,7 +8,9 @@ import com.squareup.moshi.Types; import datadog.remoteconfig.ConfigurationDeserializer; import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import datadog.trace.api.featureflag.ufc.v1.Split; @@ -33,6 +35,17 @@ final class UniversalFlagConfigParser implements ConfigurationDeserializer> INVALID_FLAGS_HOLDER = + ThreadLocal.withInitial(HashMap::new); + static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); private static final Moshi MOSHI = @@ -59,7 +72,16 @@ public ServerConfiguration deserialize(final byte[] content) throws IOException @Nullable ServerConfiguration parse(final JsonReader reader) throws IOException { - return V1_ADAPTER.fromJson(reader); + INVALID_FLAGS_HOLDER.get().clear(); + final ServerConfiguration configuration = V1_ADAPTER.fromJson(reader); + if (configuration != null) { + final Map invalid = new HashMap<>(INVALID_FLAGS_HOLDER.get()); + if (!invalid.isEmpty()) { + configuration.invalidFlags = invalid; + } + } + INVALID_FLAGS_HOLDER.get().clear(); + return configuration; } private static void requireEndOfDocument(final JsonReader reader) throws IOException { @@ -67,6 +89,101 @@ private static void requireEndOfDocument(final JsonReader reader) throws IOExcep reader.peek(); } + /** Validates the required nested UFC fields and SemVer comparands for a flag. */ + private static void validateFlag(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; + } + for (final Allocation allocation : flag.allocations) { + if (allocation == null || allocation.splits == null) { + continue; + } + for (final Split split : allocation.splits) { + if (split != null && split.shards == null) { + throw new InvalidFlagException( + "flag \"" + flagKey + "\" contains a split with missing shards"); + } + } + } + validateAndCacheSemverComparands(flagKey, flag); + } + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link + * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. + */ + private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; + } + for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { + final Allocation allocation = flag.allocations.get(allocIdx); + if (allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition.operator == null) { + continue; + } + switch (condition.operator) { + case SEMVER_EQ: + case SEMVER_NEQ: + case SEMVER_LT: + case SEMVER_LTE: + case SEMVER_GT: + case SEMVER_GTE: + if (!(condition.value instanceof String)) { + throw new InvalidSemverComparandException( + "flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" that requires string value"); + } + final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); + if (parsed == null) { + throw new InvalidSemverComparandException( + "flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" and invalid semantic version \"" + + condition.value + + "\""); + } + condition.semverComparand = parsed; + break; + default: + // Non-semver operators are not validated here + break; + } + } + } + } + } + + /** Thrown when a flag has an invalid UFC shape. */ + static final class InvalidFlagException extends IllegalArgumentException { + InvalidFlagException(final String message) { + super(message); + } + } + + /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ + static final class InvalidSemverComparandException extends IllegalArgumentException { + InvalidSemverComparandException(final String message) { + super(message); + } + } + static final class FlagMapAdapter extends JsonAdapter> { private static final Type FLAGS_TYPE = @@ -107,9 +224,14 @@ public Map fromJson(@Nonnull final JsonReader reader) throws IOExc try { final Flag flag = flagAdapter.fromJsonValue(rawFlag); if (flag != null) { + validateFlag(flagKey, flag); flags.put(flagKey, flag); } } catch (JsonDataException | IllegalArgumentException error) { + INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_FLAG); + if (error instanceof InvalidSemverComparandException) { + INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_SEMVER_COMPARAND); + } LOGGER.warn( "Dropping malformed FFE flag {} during remote config deserialization: {}", flagKey, diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java index 104adef4cbe..61332a13520 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java @@ -76,6 +76,95 @@ void rejectsTrailingJson() { + "}}{}")); } + @Test + void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag("no-allocations", ""), + booleanFlag( + "no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), + booleanFlag( + "null-split", + ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), + booleanFlag("no-rules", allocation("no-rules", "")), + booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), + booleanFlag( + "no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), + booleanFlag( + "non-semver", + allocation( + "non-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), + booleanFlag( + "valid-semver", + allocation( + "valid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2.3\"}]}]")), + booleanFlag( + "invalid-semver", + allocation( + "invalid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), + booleanFlag( + "non-string-semver", + allocation( + "non-string-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), + "\"null-flag\":null"))); + + assertNotNull(configuration); + assertTrue(configuration.flags.containsKey("no-allocations")); + assertTrue(configuration.flags.containsKey("no-splits")); + assertTrue(configuration.flags.containsKey("null-split")); + assertTrue(configuration.flags.containsKey("no-rules")); + assertTrue(configuration.flags.containsKey("no-conditions")); + assertTrue(configuration.flags.containsKey("no-operator")); + assertTrue(configuration.flags.containsKey("non-semver")); + assertTrue(configuration.flags.containsKey("valid-semver")); + assertFalse(configuration.flags.containsKey("invalid-semver")); + assertFalse(configuration.flags.containsKey("non-string-semver")); + assertFalse(configuration.flags.containsKey("null-flag")); + assertEquals(2, configuration.invalidFlags.size()); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); + + assertNotNull( + configuration + .flags + .get("valid-semver") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .semverComparand); + } + + @Test + void dropsFlagWithMissingSplitShards() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "missing-shards", + ",\"allocations\":[{\"key\":\"missing-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\"}]}]"), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("missing-shards")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); + } + + @Test + void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); + } + @Test void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then @@ -171,6 +260,35 @@ private static String configWithRawObserveFullEvaluationData(final String rawJso + "}"; } + private static String configWithFlags(final String... flags) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + String.join(",", flags) + + "}}"; + } + + private static String booleanFlag(final String key, final String allocations) { + return "\"" + + key + + "\":{" + + "\"key\":\"" + + key + + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" + + allocations + + "}"; + } + + private static String allocation(final String key, final String rules) { + return ",\"allocations\":[{\"key\":\"" + + key + + "\"" + + (rules.isEmpty() ? "" : ",\"rules\":" + rules) + + ",\"splits\":[]}]"; + } + private static String emptyConfig() { return "{" + "\"createdAt\":\"2024-04-17T19:40:53.716Z\","