diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 6f39fc831..85e25528d 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -338,6 +338,50 @@ precomputed-only `configurationFromString` helper. `Error`. Loading a usable configuration after an error emits `Ready`, followed by `ConfigurationChanged`. +### Fetching rules configuration + +Use `fetchRulesConfiguration` from the `/rules-based` entry point to fetch client-distributed rules and decode them into a `FlagsConfiguration`. Like the browser helper, it accepts `clientToken`, `env`, an optional site domain, an optional `flagSelection`, an optional `fetch` implementation, and an optional `AbortSignal`. An optional `logger` receives selection diagnostics and defaults to `console`. The helper does not require native SDK initialization or `DdFlags.enable()`. + +```tsx +import { + DatadogCoreProvider, + fetchRulesConfiguration +} from '@datadog/mobile-react-native-openfeature/rules-based'; +import { OpenFeature } from '@openfeature/react-sdk'; + +const fetchOptions = { + clientToken: '', + env: 'production', + site: 'datadoghq.com', + flagSelection: { tags: ['team:checkout'], keyPatterns: ['checkout-*'] } +}; +const provider = new DatadogCoreProvider(); +provider.setConfiguration(await fetchRulesConfiguration(fetchOptions)); +await OpenFeature.setProviderAndWait('datadog-core', provider, { + targetingKey: 'user-123', country: 'US' +}); +const client = OpenFeature.getClient('datadog-core'); +const enabled = client.getBooleanValue('checkout-new-flow', false); + +// Context changes evaluate the loaded rules locally, without another request. +await OpenFeature.setContext('datadog-core', { + targetingKey: 'user-456', country: 'CA' +}); + +// Refresh explicitly when the application needs new rules. +provider.setConfiguration(await fetchRulesConfiguration(fetchOptions)); +``` + +The helper makes one request per call, with no automatic polling, retries, persistence, or tracking. It rejects on HTTP, network, cancellation, body-read, or decoding failures. Catch refresh errors before replacing the provider configuration to keep evaluating the last successfully loaded rules. Use a client token; the helper only requests client-distributed configuration, never server-distributed rules. + +`flagSelection` requests flags matching any of the listed tags or key patterns (OR). Tags are exact, case-sensitive matches; key patterns are case-sensitive and match the whole key, with `*` matching any run of characters. Values are trimmed using Unicode whitespace, deduplicated, and sorted by UTF-8 bytes. Each list accepts at most 31 distinct nonempty values after normalization, and each value can contain at most 200 UTF-8 bytes. Empty selections are omitted quietly. + +Selection is best-effort: a malformed or over-limit selection, or one that makes the encoded request URL exceed 8,192 bytes, is not sent. The helper logs an error and requests the full configuration instead. A nonempty `DD-Flag-Selection-Ignored` response header also produces an error diagnostic when a selection was sent. Supply `logger: { error(message) { /* route diagnostics */ } }` to customize reporting. Diagnostics do not initialize the native SDK or send telemetry, and a throwing logger does not prevent configuration loading. + +When the backend applies selection, flags outside it are absent from the returned configuration. Evaluating them returns your default value with `FLAG_NOT_FOUND`, so select every flag the app evaluates. Selection is not an access-control boundary: the helper retains the full returned configuration if the server ignores selection and does not filter it locally. + +`site` uses a domain such as `datadoghq.com` or `datadoghq.eu`, not the native SDK's `US1` or `EU1` identifiers. The default is `datadoghq.com`. Fetching and evaluation do not register tracking; use the hooks below when tracking is needed. + ### Optional tracking with DatadogCoreProvider (Android only) Register exposure, evaluation, and RUM hooks independently with the evaluation-only diff --git a/packages/react-native-openfeature/package.json b/packages/react-native-openfeature/package.json index ddd824f3f..79cf6ae20 100644 --- a/packages/react-native-openfeature/package.json +++ b/packages/react-native-openfeature/package.json @@ -36,16 +36,17 @@ "scripts": { "test": "jest", "lint": "eslint .", - "prepare": "rm -rf lib && yarn bob build" + "prepare": "genversion --es6 --semi src/version.ts && rm -rf lib && yarn bob build" }, "dependencies": { - "@datadog/flagging-core": "3.1.1" + "@datadog/flagging-core": "3.3.0" }, "devDependencies": { "@datadog/mobile-react-native": "^3.11.0", "@openfeature/core": "^1.9.2", "@openfeature/web-sdk": "^1.8.0", "@testing-library/react-native": "7.0.2", + "genversion": "3.0.2", "react-native-builder-bob": "0.26.0", "react-native-gesture-handler": "1.10.3" }, diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/flag-selection/SOURCE.md b/packages/react-native-openfeature/src/__tests__/__utils__/flag-selection/SOURCE.md new file mode 100644 index 000000000..2e73be1e6 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/__utils__/flag-selection/SOURCE.md @@ -0,0 +1,5 @@ +# Shared Flag Selection Vectors + +`selection-vectors.json` is copied unchanged from [DataDog/ffe-system-test-data](https://github.com/DataDog/ffe-system-test-data/blob/caa6d1ac3e5fbda0ce57ef3ffff3772901f3f466/flag-selection/selection-vectors.json) at commit `caa6d1ac3e5fbda0ce57ef3ffff3772901f3f466`. + +The fetch-helper tests consume all 38 `sdk` expectations. Raw-query percent-decoding and server-side `selectedKeys` expectations are not SDK transport behavior and are not exercised here. Test runs use this checked-in snapshot without network access. Update shared expectations upstream, then replace this file from a pinned commit and update this provenance and the SDK-case count assertion together. diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/flag-selection/selection-vectors.json b/packages/react-native-openfeature/src/__tests__/__utils__/flag-selection/selection-vectors.json new file mode 100644 index 000000000..2daf24b92 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/__utils__/flag-selection/selection-vectors.json @@ -0,0 +1,270 @@ +{ + "flags": [ + {"key": "checkout.new-flow", "tags": ["team:payments", "checkout"]}, + {"key": "checkout.legacy", "tags": ["team:payments"]}, + {"key": "search.ranking-v2", "tags": ["team:search"]}, + {"key": "search.autocomplete", "tags": []}, + {"key": "mobile.dark-mode", "tags": ["mobile", "ui"]}, + {"key": "a*b", "tags": ["literal"]} + ], + "cases": [ + { + "name": "no parameters selects every flag", + "query": {}, + "expected": {"outcome": "applied", "selectedKeys": ["a*b", "checkout.legacy", "checkout.new-flow", "mobile.dark-mode", "search.autocomplete", "search.ranking-v2"]}, + "sdk": {"selection": {"tags": [], "keyPatterns": []}} + }, + { + "name": "tag selects flags having any of the tags", + "query": {"selected_flag_tags": ["team:search", "mobile"]}, + "expected": {"outcome": "applied", "selectedKeys": ["mobile.dark-mode", "search.ranking-v2"]}, + "sdk": {"selection": {"tags": ["mobile", "team:search"], "keyPatterns": []}} + }, + { + "name": "tags and key patterns are combined with OR", + "query": {"selected_flag_tags": ["mobile"], "selected_flag_key_patterns": ["checkout.*"]}, + "expected": {"outcome": "applied", "selectedKeys": ["checkout.legacy", "checkout.new-flow", "mobile.dark-mode"]}, + "sdk": {"selection": {"tags": ["mobile"], "keyPatterns": ["checkout.*"]}} + }, + { + "name": "key pattern must match the whole key", + "query": {"selected_flag_key_patterns": ["checkout"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["checkout"]}} + }, + { + "name": "key pattern without a star selects exactly that key", + "query": {"selected_flag_key_patterns": ["checkout.new-flow"]}, + "expected": {"outcome": "applied", "selectedKeys": ["checkout.new-flow"]}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["checkout.new-flow"]}} + }, + { + "name": "star matches an empty run and multiple stars are allowed", + "query": {"selected_flag_key_patterns": ["*search*auto*complete*"]}, + "expected": {"outcome": "applied", "selectedKeys": ["search.autocomplete"]}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["*search*auto*complete*"]}} + }, + { + "name": "leading star", + "query": {"selected_flag_key_patterns": ["*-v2"]}, + "expected": {"outcome": "applied", "selectedKeys": ["search.ranking-v2"]}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["*-v2"]}} + }, + { + "name": "lone star matches every key", + "query": {"selected_flag_key_patterns": ["*"]}, + "expected": {"outcome": "applied", "selectedKeys": ["a*b", "checkout.legacy", "checkout.new-flow", "mobile.dark-mode", "search.autocomplete", "search.ranking-v2"]}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["*"]}} + }, + { + "name": "question mark and brackets are literal", + "query": {"selected_flag_key_patterns": ["search.ranking-v?", "[a]*", "{a,b}"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["[a]*", "search.ranking-v?", "{a,b}"]}} + }, + { + "name": "braces are literal even when an alternative names a flag", + "query": {"selected_flag_key_patterns": ["{mobile.dark-mode,missing}"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["{mobile.dark-mode,missing}"]}} + }, + { + "name": "tags are never globs", + "query": {"selected_flag_tags": ["team:*"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["team:*"], "keyPatterns": []}} + }, + { + "name": "tags match case-sensitively", + "query": {"selected_flag_tags": ["Team:Payments", "MOBILE"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["MOBILE", "Team:Payments"], "keyPatterns": []}} + }, + { + "name": "key patterns match case-sensitively", + "query": {"selected_flag_key_patterns": ["CHECKOUT.*", "Mobile.Dark-Mode"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["CHECKOUT.*", "Mobile.Dark-Mode"]}} + }, + { + "name": "values are trimmed, empties dropped and duplicates removed", + "query": {"selected_flag_tags": [" mobile ", "mobile", "", " "]}, + "expected": {"outcome": "applied", "selectedKeys": ["mobile.dark-mode"]}, + "sdk": {"selection": {"tags": ["mobile"], "keyPatterns": []}} + }, + { + "name": "lists that are empty after normalization select every flag", + "query": {"selected_flag_tags": ["", " "], "selected_flag_key_patterns": [" "]}, + "expected": {"outcome": "applied", "selectedKeys": ["a*b", "checkout.legacy", "checkout.new-flow", "mobile.dark-mode", "search.autocomplete", "search.ranking-v2"]}, + "sdk": {"selection": {"tags": [], "keyPatterns": []}} + }, + { + "name": "values are trimmed of Unicode white space", + "query": {"selected_flag_tags": ["\u00a0mobile\u3000", "\u0085ui\u2028", "\t team:search \n"]}, + "expected": {"outcome": "applied", "selectedKeys": ["mobile.dark-mode", "search.ranking-v2"]}, + "sdk": {"selection": {"tags": ["mobile", "team:search", "ui"], "keyPatterns": []}} + }, + { + "name": "characters outside Unicode White_Space are not trimmed", + "query": {"selected_flag_tags": ["\ufeffmobile", "mobile\u200b"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["mobile\u200b", "\ufeffmobile"], "keyPatterns": []}} + }, + { + "name": "values sort by UTF-8 bytes", + "query": {"selected_flag_tags": ["\ud83d\ude00", "\uff5e", "a", "Z"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["Z", "a", "\uff5e", "\ud83d\ude00"], "keyPatterns": []}} + }, + { + "name": "partial match serves only the matching flags", + "query": {"selected_flag_tags": ["mobile", "missing"], "selected_flag_key_patterns": ["nothing.*"]}, + "expected": {"outcome": "applied", "selectedKeys": ["mobile.dark-mode"]}, + "sdk": {"selection": {"tags": ["missing", "mobile"], "keyPatterns": ["nothing.*"]}} + }, + { + "name": "undecodable value is malformed", + "rawQuery": "dd_env=prod&selected_flag_tags=mobile&selected_flag_tags=%zz", + "expected": {"outcome": "ignored", "reason": "malformed_selection"} + }, + { + "name": "undecodable unrelated parameter is not a selection error", + "rawQuery": "other=%zz&selected_flag_tags=mobile", + "expected": {"outcome": "applied", "selectedKeys": ["mobile.dark-mode"]} + }, + { + "name": "repeated parameters in a raw query are all read", + "rawQuery": "selected_flag_tags=mobile&selected_flag_tags=team%3Asearch&selected_flag_key_patterns=a%2Ab", + "expected": {"outcome": "applied", "selectedKeys": ["a*b", "mobile.dark-mode", "search.ranking-v2"]} + }, + { + "name": "parameter names are percent-decoded", + "rawQuery": "selected%5Fflag%5Ftags=mobile", + "expected": {"outcome": "applied", "selectedKeys": ["mobile.dark-mode"]} + }, + { + "name": "31 values in one list are allowed", + "query": {"selected_flag_tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31"], "keyPatterns": []}} + }, + { + "name": "32 values in one list are rejected", + "query": {"selected_flag_key_patterns": ["k01", "k02", "k03", "k04", "k05", "k06", "k07", "k08", "k09", "k10", "k11", "k12", "k13", "k14", "k15", "k16", "k17", "k18", "k19", "k20", "k21", "k22", "k23", "k24", "k25", "k26", "k27", "k28", "k29", "k30", "k31", "k32"]}, + "expected": {"outcome": "ignored", "reason": "too_many_values"}, + "sdk": {"dropped": "too_many_values"} + }, + { + "name": "31 values in each list are allowed", + "query": {"selected_flag_tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31"], "selected_flag_key_patterns": ["p01", "p02", "p03", "p04", "p05", "p06", "p07", "p08", "p09", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17", "p18", "p19", "p20", "p21", "p22", "p23", "p24", "p25", "p26", "p27", "p28", "p29", "p30", "p31"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31"], "keyPatterns": ["p01", "p02", "p03", "p04", "p05", "p06", "p07", "p08", "p09", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17", "p18", "p19", "p20", "p21", "p22", "p23", "p24", "p25", "p26", "p27", "p28", "p29", "p30", "p31"]}} + }, + { + "name": "32 values in one list are rejected even when the other is within the limit", + "query": {"selected_flag_tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31"], "selected_flag_key_patterns": ["p01", "p02", "p03", "p04", "p05", "p06", "p07", "p08", "p09", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17", "p18", "p19", "p20", "p21", "p22", "p23", "p24", "p25", "p26", "p27", "p28", "p29", "p30", "p31", "p32"]}, + "expected": {"outcome": "ignored", "reason": "too_many_values"}, + "sdk": {"dropped": "too_many_values"} + }, + { + "name": "duplicates do not count toward the limit", + "query": {"selected_flag_tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31", "t01", " t02 "]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31"], "keyPatterns": []}} + }, + { + "name": "key pattern of 200 bytes is allowed", + "query": {"selected_flag_key_patterns": ["*xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["*xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]}} + }, + { + "name": "key pattern of 201 bytes is invalid", + "query": {"selected_flag_key_patterns": ["*xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]}, + "expected": {"outcome": "ignored", "reason": "invalid_key_pattern"}, + "sdk": {"dropped": "invalid_key_pattern"} + }, + { + "name": "key pattern length is measured after trimming", + "query": {"selected_flag_key_patterns": [" *xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx "]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["*xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]}} + }, + { + "name": "key pattern length counts UTF-8 bytes: 100 two-byte characters are allowed", + "query": {"selected_flag_key_patterns": ["éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": [], "keyPatterns": ["éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"]}} + }, + { + "name": "key pattern length counts UTF-8 bytes: 201 bytes are invalid", + "query": {"selected_flag_key_patterns": ["xéééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"]}, + "expected": {"outcome": "ignored", "reason": "invalid_key_pattern"}, + "sdk": {"dropped": "invalid_key_pattern"} + }, + { + "name": "tag of 200 bytes is allowed", + "query": {"selected_flag_tags": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], "keyPatterns": []}} + }, + { + "name": "tag of 201 bytes is invalid", + "query": {"selected_flag_tags": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + "expected": {"outcome": "ignored", "reason": "invalid_tag"}, + "sdk": {"dropped": "invalid_tag"} + }, + { + "name": "tag length is measured after trimming", + "query": {"selected_flag_tags": [" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa "]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], "keyPatterns": []}} + }, + { + "name": "tag length counts UTF-8 bytes: 100 two-byte characters are allowed", + "query": {"selected_flag_tags": ["éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"]}, + "expected": {"outcome": "applied", "selectedKeys": []}, + "sdk": {"selection": {"tags": ["éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"], "keyPatterns": []}} + }, + { + "name": "tag length counts UTF-8 bytes: 201 bytes are invalid", + "query": {"selected_flag_tags": ["xéééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"]}, + "expected": {"outcome": "ignored", "reason": "invalid_tag"}, + "sdk": {"dropped": "invalid_tag"} + }, + { + "name": "a long tag invalidates the selection even when other tags match", + "query": {"selected_flag_tags": ["mobile", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + "expected": {"outcome": "ignored", "reason": "invalid_tag"}, + "sdk": {"dropped": "invalid_tag"} + }, + { + "name": "too_many_values takes precedence over invalid_key_pattern", + "query": {"selected_flag_tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31", "t32"], "selected_flag_key_patterns": ["*xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]}, + "expected": {"outcome": "ignored", "reason": "too_many_values"}, + "sdk": {"dropped": "too_many_values"} + }, + { + "name": "malformed_selection takes precedence over too_many_values", + "rawQuery": "selected_flag_tags=t01&selected_flag_tags=t02&selected_flag_tags=t03&selected_flag_tags=t04&selected_flag_tags=t05&selected_flag_tags=t06&selected_flag_tags=t07&selected_flag_tags=t08&selected_flag_tags=t09&selected_flag_tags=t10&selected_flag_tags=t11&selected_flag_tags=t12&selected_flag_tags=t13&selected_flag_tags=t14&selected_flag_tags=t15&selected_flag_tags=t16&selected_flag_tags=t17&selected_flag_tags=t18&selected_flag_tags=t19&selected_flag_tags=t20&selected_flag_tags=t21&selected_flag_tags=t22&selected_flag_tags=t23&selected_flag_tags=t24&selected_flag_tags=t25&selected_flag_tags=t26&selected_flag_tags=t27&selected_flag_tags=t28&selected_flag_tags=t29&selected_flag_tags=t30&selected_flag_tags=t31&selected_flag_tags=t32&selected_flag_key_patterns=%zz", + "expected": {"outcome": "ignored", "reason": "malformed_selection"} + }, + { + "name": "too_many_values takes precedence over invalid_tag", + "query": {"selected_flag_tags": ["t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + "expected": {"outcome": "ignored", "reason": "too_many_values"}, + "sdk": {"dropped": "too_many_values"} + }, + { + "name": "invalid_key_pattern takes precedence over invalid_tag", + "query": {"selected_flag_tags": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], "selected_flag_key_patterns": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + "expected": {"outcome": "ignored", "reason": "invalid_key_pattern"}, + "sdk": {"dropped": "invalid_key_pattern"} + }, + { + "name": "malformed_selection takes precedence over invalid_tag", + "rawQuery": "selected_flag_tags=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&selected_flag_tags=%zz", + "expected": {"outcome": "ignored", "reason": "malformed_selection"} + } + ] +} diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts index b2a0201d8..5de83c604 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts @@ -74,7 +74,7 @@ describe('DatadogCoreProvider', () => { it('pins the evaluator to the React Native-tested version without a semver range', () => { expect(packageJson.dependencies['@datadog/flagging-core']).toBe( - '3.1.1' + '3.3.0' ); }); diff --git a/packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts b/packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts new file mode 100644 index 000000000..77c35edf9 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts @@ -0,0 +1,660 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { OpenFeature } from '@openfeature/web-sdk'; +import { URL as NodeURL } from 'url'; + +import { version } from '../../package.json'; +import { DatadogCoreProvider, fetchRulesConfiguration } from '../rules-based'; +import type { + FlagSelection, + RulesConfigurationFetchOptions +} from '../rules-based'; + +import { matchingContext, rulesWire } from './__utils__/coreConfiguration'; +import selectionVectors from './__utils__/flag-selection/selection-vectors.json'; + +jest.mock('@datadog/mobile-react-native', () => { + throw new Error('Fetching rules must not load the native SDK'); +}); + +const options: RulesConfigurationFetchOptions = { + clientToken: 'test-client-token', + env: 'production' +}; +const rulesBytes = Uint8Array.from( + Buffer.from(JSON.parse(rulesWire).rules.response, 'base64') +); +const baseUrl = + 'https://ufc-client.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/client?dd_env='; + +function response(overrides: Partial = {}): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/protobuf' }), + arrayBuffer: jest.fn(async () => rulesBytes.buffer), + ...overrides + } as Response; +} + +describe('fetchRulesConfiguration', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('flag selection', () => { + const logger = { error: jest.fn() }; + + beforeEach(() => { + logger.error.mockReset(); + jest.spyOn(globalThis, 'URL').mockImplementation(() => { + throw new Error('URL is unavailable'); + }); + jest.spyOn(globalThis, 'URLSearchParams').mockImplementation(() => { + throw new Error('URLSearchParams is unavailable'); + }); + }); + + const sdkCases = selectionVectors.cases.filter(vector => vector.sdk); + + it('includes all pinned SDK vectors', () => { + expect(sdkCases).toHaveLength(38); + }); + + it.each(sdkCases)( + 'matches the shared SDK vector: $name', + async vector => { + const fetch = jest.fn().mockResolvedValue(response()); + const configuration = await fetchRulesConfiguration({ + ...options, + flagSelection: { + tags: vector.query?.selected_flag_tags, + keyPatterns: vector.query?.selected_flag_key_patterns + }, + fetch, + logger + }); + expect(fetch).toHaveBeenCalledTimes(1); + const url = new NodeURL(fetch.mock.calls[0][0]); + const expected: [string, string][] = [['dd_env', options.env]]; + if (vector.sdk?.dropped) { + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `flagSelection was not sent (${vector.sdk.dropped}: ` + ) + ); + } else { + expected.push( + ...(vector.sdk?.selection?.tags ?? []).map( + tag => + ['selected_flag_tags', tag] as [string, string] + ), + ...(vector.sdk?.selection?.keyPatterns ?? []).map( + pattern => + ['selected_flag_key_patterns', pattern] as [ + string, + string + ] + ) + ); + expect(logger.error).not.toHaveBeenCalled(); + } + expect(Array.from(url.searchParams.entries())).toEqual( + expected + ); + expect(fetch.mock.calls[0][1].headers['dd-client-token']).toBe( + options.clientToken + ); + expect(url.toString()).not.toContain(options.clientToken); + expect(configuration.rules?.response).toBeDefined(); + } + ); + + it('RFC 3986 encodes selection values without mutating the input', async () => { + const flagSelection: FlagSelection = Object.freeze({ + tags: Object.freeze([" team:checkout &a=1/+%!'() ", 'mobile']), + keyPatterns: Object.freeze(['checkout-*', 'checkout-*']) + }); + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + flagSelection, + fetch, + logger + }); + expect(fetch.mock.calls[0][0]).toBe( + `${baseUrl}production&selected_flag_tags=mobile&selected_flag_tags=team%3Acheckout%20%26a%3D1%2F%2B%25%21%27%28%29&selected_flag_key_patterns=checkout-%2A` + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it.each([ + undefined, + null, + {}, + { tags: [], keyPatterns: [] }, + { tags: [' ', '\u0085'] } + ])('omits empty selection quietly: %j', async flagSelection => { + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + flagSelection: flagSelection as FlagSelection, + fetch, + logger + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][0]).toBe(`${baseUrl}production`); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it.each([ + true, + 'checkout', + [], + { tags: 'checkout' }, + { keyPatterns: [1] }, + { tags: ['\ud800'], keyPatterns: ['valid-*'] }, + { tags: new Array(1) } + ])( + 'drops the entire malformed selection with a diagnostic: %j', + async flagSelection => { + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + flagSelection: flagSelection as FlagSelection, + fetch, + logger + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][0]).toBe(`${baseUrl}production`); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'flagSelection was not sent (malformed_selection: ' + ) + ); + } + ); + + it.each(['getter', 'proxy', 'unstable item'])( + 'safely drops a throwing %s without exposing input data', + async kind => { + let flagSelection: FlagSelection; + if (kind === 'getter') { + flagSelection = { + get tags(): string[] { + throw new Error(options.clientToken); + } + }; + } else if (kind === 'proxy') { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + flagSelection = proxy; + } else { + let reads = 0; + const tags = ['valid']; + Object.defineProperty(tags, 0, { + get: () => (++reads === 1 ? 'valid' : '\ud800') + }); + flagSelection = { tags }; + } + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + flagSelection, + fetch, + logger + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][0]).toBe(`${baseUrl}production`); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('(malformed_selection: ') + ); + expect(logger.error.mock.calls[0][0]).not.toContain( + options.clientToken + ); + } + ); + + it.each([8192, 8193])( + 'enforces the encoded URL limit at %i bytes', + async length => { + const suffix = + '&selected_flag_tags=mobile&selected_flag_key_patterns=checkout-%2A'; + const env = 'e'.repeat(length - baseUrl.length - suffix.length); + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + env, + flagSelection: { + tags: ['mobile'], + keyPatterns: ['checkout-*'] + }, + fetch, + logger + }); + expect(fetch).toHaveBeenCalledTimes(1); + if (length === 8192) { + expect(fetch.mock.calls[0][0]).toBe( + `${baseUrl}${env}${suffix}` + ); + expect(logger.error).not.toHaveBeenCalled(); + } else { + expect(fetch.mock.calls[0][0]).toBe(`${baseUrl}${env}`); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'the request URL is at most 8192 bytes' + ) + ); + } + } + ); + + it.each(['too_many_values', 'future_reason', '', null])( + 'handles an ignored-selection header: %s', + async reason => { + const headers = new Headers(); + if (reason !== null) { + headers.set('DD-Flag-Selection-Ignored', reason); + } + const fetch = jest + .fn() + .mockResolvedValue(response({ headers })); + const configuration = await fetchRulesConfiguration({ + ...options, + flagSelection: { tags: ['not-in-fixture'] }, + fetch, + logger + }); + const provider = new DatadogCoreProvider(); + provider.setConfiguration(configuration); + await OpenFeature.setProviderAndWait( + 'selection-ignored', + provider, + matchingContext + ); + expect( + OpenFeature.getClient('selection-ignored').getBooleanValue( + 'test-flag', + false + ) + ).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + if (reason) { + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + `flagSelection was ignored by the server (${reason}). Loaded the full flag configuration.` + ); + } else { + expect(logger.error).not.toHaveBeenCalled(); + } + } + ); + + it('does not report server-ignored selection when no selection was sent', async () => { + const fetch = jest.fn().mockResolvedValue( + response({ + headers: new Headers({ + 'DD-Flag-Selection-Ignored': 'future_reason' + }) + }) + ); + await fetchRulesConfiguration({ ...options, fetch, logger }); + expect(logger.error).not.toHaveBeenCalled(); + await fetchRulesConfiguration({ + ...options, + flagSelection: ({ + tags: 'invalid' + } as unknown) as FlagSelection, + fetch, + logger + }); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('flagSelection was not sent') + ); + }); + + it('uses console diagnostics by default', async () => { + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + flagSelection: ({ + tags: 'invalid' + } as unknown) as FlagSelection, + fetch + }); + expect(error).toHaveBeenCalledTimes(1); + }); + + it('loads a server-ignored response even when the logger throws', async () => { + logger.error.mockImplementation(() => { + throw new Error('logger failed'); + }); + const fetch = jest.fn().mockResolvedValue( + response({ + headers: new Headers({ + 'DD-Flag-Selection-Ignored': 'future_reason' + }) + }) + ); + await expect( + fetchRulesConfiguration({ + ...options, + flagSelection: { tags: ['mobile'] }, + fetch, + logger + }) + ).resolves.toHaveProperty('rules'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('does not report a full configuration load for a failed HTTP response', async () => { + const fetch = jest.fn().mockResolvedValue( + response({ + ok: false, + status: 503, + statusText: 'Unavailable', + headers: new Headers({ + 'DD-Flag-Selection-Ignored': 'future_reason' + }) + }) + ); + await expect( + fetchRulesConfiguration({ + ...options, + flagSelection: { tags: ['mobile'] }, + fetch, + logger + }) + ).rejects.toThrow( + 'Failed to fetch flag configuration: Unavailable' + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('does not let a throwing logger interrupt fallback or replace an HTTP failure', async () => { + logger.error.mockImplementation(() => { + throw new Error('logger failed'); + }); + const fetch = jest + .fn() + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce( + response({ + ok: false, + status: 503, + statusText: 'Unavailable' + }) + ); + const fetchOptions = { + ...options, + flagSelection: ({ + tags: 'invalid' + } as unknown) as FlagSelection, + fetch, + logger + }; + await expect( + fetchRulesConfiguration(fetchOptions) + ).resolves.toHaveProperty('rules'); + await expect(fetchRulesConfiguration(fetchOptions)).rejects.toThrow( + 'Failed to fetch flag configuration: Unavailable' + ); + expect(fetch).toHaveBeenCalledTimes(2); + }); + }); + + it('fetches and decodes client rules with RN headers and a retrieval timestamp', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-10-20T12:00:00Z')); + const fetch = jest.fn().mockResolvedValue(response()); + const configuration = await fetchRulesConfiguration({ + ...options, + fetch + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://ufc-client.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/client?dd_env=production', + { + method: 'GET', + headers: { + Accept: 'application/protobuf', + 'dd-client-token': 'test-client-token', + 'DD-Client-Library-Language': 'react-native', + 'DD-Client-Library-Version': version + }, + signal: undefined + } + ); + expect(configuration.rules?.fetchedAt).toBe(Date.now()); + expect(configuration.rules?.response).toBeDefined(); + expect(configuration.precomputed).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([ + 'datadoghq.com', + 'us3.datadoghq.com', + 'us5.datadoghq.com', + 'ap1.datadoghq.com', + 'ap2.datadoghq.com', + 'uk1.datadoghq.com', + 'datadoghq.eu', + 'datad0g.com' + ])('uses the client rules endpoint for %s', async site => { + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ ...options, site, fetch }); + expect(fetch.mock.calls[0][0]).toBe( + `https://ufc-client.ff-cdn.${site}/api/v2/feature-flagging/config/rules-based/client?dd_env=production` + ); + }); + + it.each([ + 'ddog-gov.com', + 'US1', + '', + 'https://datadoghq.com', + 'datadoghq.com.attacker.example', + 'datadoghq.com@attacker.example', + 'datadoghq.com/path' + ])( + 'rejects unsupported sites before sending the client token: %s', + async site => { + const fetch = jest.fn(); + await expect( + fetchRulesConfiguration({ ...options, site, fetch }) + ).rejects.toThrow('Unsupported site'); + expect(fetch).not.toHaveBeenCalled(); + } + ); + + it('encodes the environment without requiring a URL polyfill', async () => { + jest.spyOn(globalThis, 'URL').mockImplementation(() => { + throw new Error('URL is unavailable'); + }); + jest.spyOn(globalThis, 'URLSearchParams').mockImplementation(() => { + throw new Error('URLSearchParams is unavailable'); + }); + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + env: 'QA & dogfood/#?=+\u00e9', + fetch + }); + expect(fetch.mock.calls[0][0]).toBe( + `https://ufc-client.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/client?dd_env=${encodeURIComponent( + 'QA & dogfood/#?=+\u00e9' + )}` + ); + }); + + it('uses global fetch by default and forwards an abort signal', async () => { + const fetch = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(response()); + const controller = new AbortController(); + await fetchRulesConfiguration({ + ...options, + signal: controller.signal + }); + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: controller.signal }) + ); + }); + + it.each(['application/json', 'application/vnd.api+json; charset=utf-8'])( + 'reports an HTTP error from %s without decoding rules', + async contentType => { + const failed = response({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'content-type': contentType }), + json: jest.fn().mockResolvedValue({ + errors: [{ detail: 'Invalid client token' }] + }) + }); + const fetch = jest.fn().mockResolvedValue(failed); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow( + 'Failed to fetch flag configuration: Invalid client token' + ); + expect(failed.arrayBuffer).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + } + ); + + it.each([null, {}, { errors: [] }, { errors: [{ detail: 42 }] }])( + 'preserves the HTTP status for an unrecognized error body: %j', + async body => { + const fetch = jest.fn().mockResolvedValue( + response({ + ok: false, + status: 503, + statusText: '', + headers: new Headers({ + 'content-type': 'application/json' + }), + json: jest.fn().mockResolvedValue(body) + }) + ); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow('Failed to fetch flag configuration: HTTP 503'); + } + ); + + it('preserves an HTTP failure when its JSON error body cannot be read', async () => { + const fetch = jest.fn().mockResolvedValue( + response({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + headers: new Headers({ 'content-type': 'application/json' }), + json: jest + .fn() + .mockRejectedValue(new SyntaxError('Invalid JSON')) + }) + ); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow('Failed to fetch flag configuration: Bad Gateway'); + }); + + it.each(['network', 'abort'])( + 'propagates a %s rejection without retrying', + async kind => { + const error = new Error(kind); + error.name = kind === 'abort' ? 'AbortError' : 'TypeError'; + const fetch = jest.fn().mockRejectedValue(error); + const controller = new AbortController(); + if (kind === 'abort') { + controller.abort(); + } + await expect( + fetchRulesConfiguration({ + ...options, + fetch, + signal: controller.signal + }) + ).rejects.toBe(error); + expect(fetch).toHaveBeenCalledTimes(1); + } + ); + + it('rejects unreadable and malformed protobuf bodies', async () => { + const error = new Error('Unable to read response'); + const fetch = jest + .fn() + .mockResolvedValueOnce( + response({ arrayBuffer: jest.fn().mockRejectedValue(error) }) + ) + .mockResolvedValueOnce( + response({ + arrayBuffer: jest + .fn() + .mockResolvedValue(new Uint8Array([255]).buffer) + }) + ); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toBe(error); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow(); + }); + + it('evaluates fetched rules locally and preserves them after a failed explicit refresh', async () => { + const fetch = jest.fn().mockResolvedValue(response()); + const provider = new DatadogCoreProvider(); + const configuration = await fetchRulesConfiguration({ + ...options, + fetch + }); + provider.setConfiguration(configuration); + await OpenFeature.setProviderAndWait( + 'fetched-rules', + provider, + matchingContext + ); + const client = OpenFeature.getClient('fetched-rules'); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.setContext('fetched-rules', { + targetingKey: 'user-2', + country: 'CA' + }); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + + const failure = new Error('Offline'); + fetch.mockRejectedValueOnce(failure); + const refresh = async () => { + provider.setConfiguration( + await fetchRulesConfiguration({ ...options, fetch }) + ); + }; + await expect(refresh()).rejects.toBe(failure); + expect(provider.getConfiguration()).toBe(configuration); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts index a3daebdf5..87a7ee22c 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -13,7 +13,8 @@ import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; import type { DatadogTrackingHook, DatadogTrackingHooks } from '../index'; import { coreConfigurationFromString, - DatadogCoreProvider + DatadogCoreProvider, + fetchRulesConfiguration } from '../rules-based'; import { composeDatadogTrackingHooks, @@ -25,7 +26,8 @@ import { import { matchingContext, precomputedConfiguration, - rulesConfiguration + rulesConfiguration, + rulesWire } from './__utils__/coreConfiguration'; // Exercise the real evaluator, OpenFeature lifecycle, and JS bridge adapter. Only native @@ -473,6 +475,169 @@ describe('optional native tracking hooks', () => { expect(native.setEvaluationContext).not.toHaveBeenCalled(); }); + it('tracks fetched rules without fetching again for context changes or hook cleanup', async () => { + const bytes = Uint8Array.from( + Buffer.from(JSON.parse(rulesWire).rules.response, 'base64') + ); + const fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => bytes.buffer + }); + const configuration = await fetchRulesConfiguration({ + clientToken: 'test-token', + env: 'test', + fetch + }); + const provider = new DatadogCoreProvider(); + provider.setConfiguration(configuration); + const domain = `tracking-${++sequence}`; + await OpenFeature.setProviderAndWait(domain, provider, matchingContext); + const client = OpenFeature.getClient(domain); + client.getBooleanValue('test-flag', false); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ); + await tracking.initialize(); + client.addHooks(...tracking.hooks); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.setContext(domain, { + targetingKey: 'user-2', + country: 'CA' + }); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + client.clearHooks(); + await tracking.shutdown(); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(provider.getConfiguration()).toBe(configuration); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(2); + expect(rum).toHaveBeenCalledTimes(2); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('uses refreshed rules and tracking metadata without registering hooks again', async () => { + // Re-encode changed rules so both requests exercise the actual protobuf decoder. + const replacement = rulesConfiguration(); + const replacementFlag = replacement.rules.response.flags['test-flag']; + replacementFlag.variations[0].value = { + case: 'booleanValue', + value: false + }; + replacement.rules.response.strings[ + replacementFlag.variations[0].keyStringIndex + ] = 'refreshed-off'; + replacementFlag.allocations[0].key = 'refreshed-allocation'; + replacementFlag.allocations[0].splits[0].serialId = 8; + const responses = [rulesWire, configurationToString(replacement)].map( + wire => { + const bytes = Uint8Array.from( + Buffer.from(JSON.parse(wire).rules.response, 'base64') + ); + return { ok: true, arrayBuffer: async () => bytes.buffer }; + } + ); + const fetch = jest + .fn() + .mockResolvedValueOnce(responses[0]) + .mockResolvedValueOnce(responses[1]); + const fetchOptions = { clientToken: 'test-token', env: 'test', fetch }; + const provider = new DatadogCoreProvider(); + provider.setConfiguration(await fetchRulesConfiguration(fetchOptions)); + const domain = `tracking-${++sequence}`; + await OpenFeature.setProviderAndWait(domain, provider, matchingContext); + const client = OpenFeature.getClient(domain); + const addHooks = jest.spyOn(client, 'addHooks'); + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ clientName: 'refresh-owner' }), + createDatadogEvaluationLoggingHook({ clientName: 'refresh-owner' }), + createDatadogRumTrackingHook() + ); + await tracking.initialize(); + client.addHooks(...tracking.hooks); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + variant: 'on', + flagMetadata: { + allocationKey: 'allocation', + __dd_split_serial_id: 7 + } + }); + const refreshed = await fetchRulesConfiguration(fetchOptions); + provider.setConfiguration(refreshed); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(1); + expect(rum).toHaveBeenCalledTimes(1); + + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + variant: 'refreshed-off', + flagMetadata: { + allocationKey: 'refreshed-allocation', + __dd_split_serial_id: 8 + } + }); + const expectedRecords = [ + [ + 'refresh-owner', + 'test-flag', + { + variationKey: 'on', + allocationKey: 'allocation', + serialId: '7', + doLog: true + }, + 'user-1', + { country: 'US' } + ], + [ + 'refresh-owner', + 'test-flag', + { + variationKey: 'refreshed-off', + allocationKey: 'refreshed-allocation', + serialId: '8', + doLog: true + }, + 'user-1', + { country: 'US' } + ] + ]; + expect(native.trackExposure.mock.calls).toEqual(expectedRecords); + expect(native.trackEvaluationEvent.mock.calls).toEqual( + expectedRecords.map(record => [...record, null]) + ); + expect(rum.mock.calls).toEqual([ + ['default', 'test-flag', 'on', 'user-1', { country: 'US' }], + [ + 'default', + 'test-flag', + 'refreshed-off', + 'user-1', + { country: 'US' } + ] + ]); + expect(provider.getConfiguration()).toBe(refreshed); + expect(addHooks).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + it('preserves split serial IDs for native exposure deduplication', async () => { const { client, provider } = await configuredClient( createDatadogExposureLoggingHook() diff --git a/packages/react-native-openfeature/src/fetchRulesConfiguration.ts b/packages/react-native-openfeature/src/fetchRulesConfiguration.ts new file mode 100644 index 000000000..bcf4618e9 --- /dev/null +++ b/packages/react-native-openfeature/src/fetchRulesConfiguration.ts @@ -0,0 +1,116 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { configurationFromRulesBinary } from '@datadog/flagging-core/rules-based'; +import type { FlagSelection, FlagsConfiguration } from '@datadog/flagging-core'; +import { timeStampNow } from '@datadog/flagging-core'; + +import { applyFlagSelection, reportSelectionError } from './flagSelection'; +import { version } from './version'; + +const supportedSites = new Set([ + 'datadoghq.com', + 'us3.datadoghq.com', + 'us5.datadoghq.com', + 'ap1.datadoghq.com', + 'ap2.datadoghq.com', + 'uk1.datadoghq.com', + 'datadoghq.eu', + 'datad0g.com' +]); + +export interface RulesConfigurationFetchOptions { + clientToken: string; + env: string; + /** Datadog site domain, such as 'datadoghq.eu'. Defaults to 'datadoghq.com'. */ + site?: string; + /** Request flags matching any of these tags or key patterns. Invalid or over-limit selections are not sent. */ + flagSelection?: FlagSelection; + /** Reports dropped or server-ignored selections. Defaults to console. */ + logger?: { error(message: string): void }; + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; +} + +/** Fetches client-distributed rules once, without initializing the SDK or registering tracking. */ +export async function fetchRulesConfiguration( + options: RulesConfigurationFetchOptions +): Promise { + const site = options.site ?? 'datadoghq.com'; + if (!supportedSites.has(site)) { + throw new Error(`Unsupported site for flagging endpoints: ${site}`); + } + + // RN's built-in URLSearchParams.set is not implemented on supported versions. + const baseUrl = `https://ufc-client.ff-cdn.${site}/api/v2/feature-flagging/config/rules-based/client?dd_env=${encodeURIComponent( + options.env + )}`; + const logger = options.logger ?? console; + const { url, selected } = applyFlagSelection( + baseUrl, + options.flagSelection, + logger + ); + const fetchedAt = timeStampNow(); + const fetchImplementation = options.fetch ?? globalThis.fetch; + const response = await fetchImplementation(url, { + method: 'GET', + headers: { + Accept: 'application/protobuf', + 'dd-client-token': options.clientToken, + 'DD-Client-Library-Language': 'react-native', + 'DD-Client-Library-Version': version + }, + signal: options.signal + }); + if (!response.ok) { + throw new Error( + `Failed to fetch flag configuration: ${await getErrorMessage( + response + )}` + ); + } + + if (selected) { + const reason = response.headers.get('DD-Flag-Selection-Ignored'); + if (reason) { + reportSelectionError( + logger, + `flagSelection was ignored by the server (${reason}). Loaded the full flag configuration.` + ); + } + } + + const configuration = configurationFromRulesBinary( + new Uint8Array(await response.arrayBuffer()) + ); + if (configuration.rules) { + configuration.rules.fetchedAt = fetchedAt; + } + return configuration; +} + +async function getErrorMessage(response: Response): Promise { + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + .trim(); + if ( + contentType === 'application/vnd.api+json' || + contentType === 'application/json' + ) { + try { + const body = await response.json(); + const detail = body?.errors?.[0]?.detail; + if (typeof detail === 'string' && detail.length > 0) { + return detail; + } + } catch { + // Preserve the HTTP failure even when its error body is not valid JSON. + } + } + return response.statusText || `HTTP ${response.status}`; +} diff --git a/packages/react-native-openfeature/src/flagSelection.ts b/packages/react-native-openfeature/src/flagSelection.ts new file mode 100644 index 000000000..cc3eba957 --- /dev/null +++ b/packages/react-native-openfeature/src/flagSelection.ts @@ -0,0 +1,92 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { FlagSelection, FlagSelectionError } from '@datadog/flagging-core'; +import { + canonicalizeFlagSelection, + MAX_FLAG_SELECTION_KEY_PATTERN_BYTES, + MAX_FLAG_SELECTION_TAG_BYTES, + MAX_FLAG_SELECTION_VALUES +} from '@datadog/flagging-core'; + +// Fastly answers URLs over 8 KB with 414 URI Too Long: https://docs.fastly.com/products/network-services-resource-limits +// Keep encoding and this limit aligned with openfeature-js-client's transport/flagSelection.ts. +const MAX_RULES_URL_BYTES = 8192; + +const LIMIT_DESCRIPTIONS: Record = { + malformed_selection: + 'flagSelection must be an object with optional tags and keyPatterns string arrays', + too_many_values: `each list accepts at most ${MAX_FLAG_SELECTION_VALUES} values`, + invalid_key_pattern: `each key pattern is at most ${MAX_FLAG_SELECTION_KEY_PATTERN_BYTES} UTF-8 bytes`, + invalid_tag: `each tag is at most ${MAX_FLAG_SELECTION_TAG_BYTES} UTF-8 bytes` +}; + +type SelectionLogger = { error(message: string): void }; + +export function reportSelectionError( + logger: SelectionLogger, + message: string +): void { + try { + logger.error(message); + } catch { + // Diagnostics must not prevent configuration loading or replace a fetch error. + } +} + +export function applyFlagSelection( + url: string, + input: FlagSelection | undefined, + logger: SelectionLogger +): { url: string; selected: boolean } { + const drop = ( + reason: FlagSelectionError, + limit = LIMIT_DESCRIPTIONS[reason] + ) => { + reportSelectionError( + logger, + `flagSelection was not sent (${reason}: ${limit}). Loading the full flag configuration.` + ); + return { url, selected: false }; + }; + try { + const { selection, error } = canonicalizeFlagSelection(input); + if (error) { + return drop(error); + } + if (!selection) { + return { url, selected: false }; + } + const params = [ + ...selection.tags.map( + tag => `selected_flag_tags=${encodeQueryValue(tag)}` + ), + ...selection.keyPatterns.map( + pattern => + `selected_flag_key_patterns=${encodeQueryValue(pattern)}` + ) + ]; + const selectedUrl = `${url}&${params.join('&')}`; + // Percent-encoding makes the complete URL ASCII, so its length is its byte length. + if (selectedUrl.length > MAX_RULES_URL_BYTES) { + return drop( + 'too_many_values', + `the request URL is at most ${MAX_RULES_URL_BYTES} bytes` + ); + } + return { url: selectedUrl, selected: true }; + } catch { + // Getters, proxies, and encoding failures can expose caller data through raw errors. + return drop('malformed_selection'); + } +} + +function encodeQueryValue(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + char => `%${char.charCodeAt(0).toString(16).toUpperCase()}` + ); +} diff --git a/packages/react-native-openfeature/src/rules-based.ts b/packages/react-native-openfeature/src/rules-based.ts index 739672b22..3e84bc2dd 100644 --- a/packages/react-native-openfeature/src/rules-based.ts +++ b/packages/react-native-openfeature/src/rules-based.ts @@ -12,7 +12,9 @@ import { DatadogCoreEvaluationProvider } from './datadogCoreProvider'; // Rules parsing loads Protobuf-ES. Keep it behind this entry, as the browser SDK does. export { configurationFromString as coreConfigurationFromString } from '@datadog/flagging-core/rules-based'; -export type { FlagsConfiguration } from '@datadog/flagging-core'; +export type { FlagSelection, FlagsConfiguration } from '@datadog/flagging-core'; +export { fetchRulesConfiguration } from './fetchRulesConfiguration'; +export type { RulesConfigurationFetchOptions } from './fetchRulesConfiguration'; /** * Evaluates manually supplied precomputed or rules-based configurations in JavaScript using diff --git a/packages/react-native-openfeature/src/version.ts b/packages/react-native-openfeature/src/version.ts new file mode 100644 index 000000000..6adedc84b --- /dev/null +++ b/packages/react-native-openfeature/src/version.ts @@ -0,0 +1,2 @@ +// generated by genversion +export const version = '3.11.0'; diff --git a/yarn.lock b/yarn.lock index 9ab9f02d5..b186f8eba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1963,13 +1963,13 @@ __metadata: languageName: node linkType: hard -"@datadog/flagging-core@npm:3.1.1": - version: 3.1.1 - resolution: "@datadog/flagging-core@npm:3.1.1" +"@datadog/flagging-core@npm:3.3.0": + version: 3.3.0 + resolution: "@datadog/flagging-core@npm:3.3.0" dependencies: "@bufbuild/protobuf": ^2.13.0 spark-md5: ^3.0.2 - checksum: e5ff0c5959366d278a2e07ec2c0851bbab3e8b514a02bf79f790e730145f5fd8ae52a14068e51a844142643140563a2f34d1b56a4c8e32c85fef2a14cfe21eb6 + checksum: e9bdb9e836b4a68c00a3cf1a0ac29dc0c25dc889de0ee7e8db908d7262f8012cc1db7a53e3fc35970c2f65249122c009575263f6e42cfd1ff1df9e95db9b0c48 languageName: node linkType: hard @@ -2085,11 +2085,12 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native-openfeature@workspace:packages/react-native-openfeature" dependencies: - "@datadog/flagging-core": 3.1.1 + "@datadog/flagging-core": 3.3.0 "@datadog/mobile-react-native": ^3.11.0 "@openfeature/core": ^1.9.2 "@openfeature/web-sdk": ^1.8.0 "@testing-library/react-native": 7.0.2 + genversion: 3.0.2 react-native-builder-bob: 0.26.0 react-native-gesture-handler: 1.10.3 peerDependencies: