From 7ccd5f4c8a4eea568654870998b3662d9d991200 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Mon, 2 Nov 2026 18:55:17 +0000 Subject: [PATCH 01/10] test(ffe): apply server flag selection in the mocked backend The JSON rules-based/server endpoint of the mocked backend now accepts selected_flag_tags and selected_flag_key_patterns, applies them to the shared ufc-config.json with the shared tags file, and sends DD-Flag-Selection-Ignored with the full configuration for an invalid selection, following ffe-service semantics. A test-only control forces an ignored reason. The fixture sync vendors flag-selection/ from ffe-system-test-data. A new parametric test, under the new feature_flags_configuration_filtering feature, checks that a server SDK sends a tag selection and serves only the selected flags. Every manifest gates it until each SDK ships the provider option. --- docs/mocked_backend/README.md | 2 +- manifests/cpp.yml | 1 + manifests/dotnet.yml | 1 + manifests/golang.yml | 1 + manifests/java.yml | 1 + manifests/nodejs.yml | 1 + manifests/php.yml | 1 + manifests/python.yml | 1 + manifests/ruby.yml | 1 + manifests/rust.yml | 1 + .../flag-selection/README.md | 200 +++++++++++++ .../flag-selection/selection-vectors.json | 270 ++++++++++++++++++ .../flag-selection/ufc-config-tags.json | 7 + .../test_ffe/test_flag_selection.py | 62 ++++ tests/test_the_test/test_mock_backend_v2.py | 12 +- .../test_mock_ffe_flag_selection.py | 228 +++++++++++++++ .../test_the_test/test_update_ffe_fixtures.py | 20 ++ utils/_context/containers.py | 7 +- utils/_features.py | 5 + utils/mocked_backend/_backend_ffe.py | 89 +++++- utils/mocked_backend/_client.py | 10 + utils/mocked_backend/_ffe_flag_selection.py | 180 ++++++++++++ utils/mocked_backend/ffe.py | 10 + utils/scripts/update_ffe_fixtures.py | 25 ++ 24 files changed, 1125 insertions(+), 11 deletions(-) create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/flag-selection/selection-vectors.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json create mode 100644 tests/parametric/test_ffe/test_flag_selection.py create mode 100644 tests/test_the_test/test_mock_ffe_flag_selection.py create mode 100644 utils/mocked_backend/_ffe_flag_selection.py diff --git a/docs/mocked_backend/README.md b/docs/mocked_backend/README.md index 2cd6c9cce22..6a3d9a73ccd 100644 --- a/docs/mocked_backend/README.md +++ b/docs/mocked_backend/README.md @@ -23,7 +23,7 @@ One single way to observe what is sent to the Datadog backend, whatever the setu * **HTTPS on many hostnames**: `mocked-backend.invalid` and the intake hostnames derived from it (`trace.agent.`, `config.`, `instrumentation-telemetry-intake.`, `debugger-intake.`...) are network aliases of its container. Its certificate is generated when it starts, for all of them, signed by the mitmproxy CA (`utils/proxy/.mitmproxy`) the senders trust. * **Remote config**: it serves the remote config backend by default (no config until a test sends one). Tests send configs with `remote_config.send_state`, which sets them on the mocked backend, then waits for the library to apply them. See [Remote config](../edit/remote-config.md). * **Controlled responses**: `MockedBackendResponse` overrides the answer on a path, for the whole session (`MockedBackendContainer.internal_mocked_responses`) or at runtime (`client.set_mocked_responses`), like the proxy's mocked responses. -* **Feature flagging**: endpoints serving the agentless FFE configuration, with scripted responses (`client.ffe_set_response(s)`) and counters (`client.ffe_get_status`). +* **Feature flagging**: endpoints serving the agentless FFE configuration, with scripted responses (`client.ffe_set_response(s)`) and counters (`client.ffe_get_status`). The JSON configuration applies flag selection (`selected_flag_tags`, `selected_flag_key_patterns`) like production, with the shared tags in `ffe-system-test-data/flag-selection/ufc-config-tags.json`; `client.ffe_set_flag_selection_ignored_reason(...)` makes it ignore valid selections, as when production cannot load tags. * **Control endpoints**: under `/system-tests/`, never recorded, used by `MockedBackendClient` from the test runner. ## Using it in a scenario diff --git a/manifests/cpp.yml b/manifests/cpp.yml index bc2ba4b839d..a9a59da329b 100644 --- a/manifests/cpp.yml +++ b/manifests/cpp.yml @@ -190,6 +190,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: missing_feature tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: irrelevant (cpp is not in scope for v1 agentless server SDK configuration-source coverage) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 2dd92aa6724..61b3a5e7853 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -1215,6 +1215,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_flag_evaluation: missing_feature # Created by easy win activation script tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/golang.yml b/manifests/golang.yml index 1bed7c35590..faaaa996f03 100644 --- a/manifests/golang.yml +++ b/manifests/golang.yml @@ -2190,6 +2190,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: v2.13.0-dev tests/parametric/test_ffe/test_exposure_hooks.py::Test_FFE_ExposureHooks::test_evaluation_feature_aggregation: missing_feature (Test agent needs EVP flagevaluation capture) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: v2.10.1 tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata: diff --git a/manifests/java.yml b/manifests/java.yml index 08b1f162174..dd721b597c5 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -4272,6 +4272,7 @@ manifest: tests/parametric/test_ffe/test_exposure_hooks.py: v1.68.0-SNAPSHOT tests/parametric/test_ffe/test_exposure_hooks.py::Test_FFE_ExposureHooks::test_evaluation_feature_aggregation: missing_feature (Test agent needs EVP flagevaluation capture) tests/parametric/test_ffe/test_exposure_hooks.py::Test_FFE_ExposureHooks::test_multiprovider_exposure_guard: irrelevant (Go exposure hook composition guard) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: v1.66.0 tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/nodejs.yml b/manifests/nodejs.yml index a22f77adf91..2fd3d47f71f 100644 --- a/manifests/nodejs.yml +++ b/manifests/nodejs.yml @@ -2704,6 +2704,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_dependent_flags: missing_feature (FFL-1694) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: 7.0.0-pre # Exact development version until the first containing release is confirmed. tests/parametric/test_ffe/test_serial_id_metadata.py: v6.18.0 tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/php.yml b/manifests/php.yml index a8be55a1642..dd673e28893 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -1635,6 +1635,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_dependent_flags: missing_feature (FFL-1694) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/python.yml b/manifests/python.yml index 7f14c4e2e6e..77d43130df7 100644 --- a/manifests/python.yml +++ b/manifests/python.yml @@ -3157,6 +3157,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_dependent_flags: missing_feature (FFL-1694) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/ruby.yml b/manifests/ruby.yml index ce005e438f3..f18b6721266 100644 --- a/manifests/ruby.yml +++ b/manifests/ruby.yml @@ -2715,6 +2715,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_dependent_flags: missing_feature (FFL-1694) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: missing_feature (The flag selection provider option is not implemented) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/manifests/rust.yml b/manifests/rust.yml index 8de4581130c..6896f548de9 100644 --- a/manifests/rust.yml +++ b/manifests/rust.yml @@ -304,6 +304,7 @@ manifest: tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_flag_evaluation: missing_feature tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Split_Features: incomplete_test_app (Split-feature hook capture is currently implemented only in the Java parametric client) tests/parametric/test_ffe/test_exposure_hooks.py: incomplete_test_app (Exposure hook observations are not implemented) + tests/parametric/test_ffe/test_flag_selection.py: irrelevant (rust is not in scope for v1 agentless server SDK configuration-source coverage) tests/parametric/test_ffe/test_rules_configuration.py: missing_feature (Standalone rules configuration helpers are currently tested only in Node.js) tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_serial_id_metadata.py::Test_FFE_Serial_Id_Metadata_Go: irrelevant (Legacy Go metadata key) diff --git a/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md new file mode 100644 index 00000000000..73c8e6c90e4 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md @@ -0,0 +1,200 @@ +# Flag Selection Vectors (`flag-selection/`) + +`selection-vectors.json` defines how a flag configuration endpoint narrows its +response to the flags an SDK selected, and how an SDK canonicalizes the +selection it sends. Every server-side implementation and every SDK that supports +flag selection runs these cases, so they agree on matching, normalization, +limits, and the reason reported when a selection is ignored. + +Each case carries two kinds of expectation: + +- `expected`: what the server does with the selection in the request. +- `sdk` (optional): the canonical selection an SDK sends for the same input, or + the reason the SDK drops it. + +Two request forms carry the same selection: + +- GET rules-based configuration endpoints read the `selected_flag_tags` and + `selected_flag_key_patterns` query parameters. Each parameter may repeat. +- `POST /precompute-assignments` reads a `flag_selection` body field shaped as + `{"tags": [...], "key_patterns": [...]}`. + +The limits come from the UFC Tag Filtering RFC. + +## Format + +```json +{ + "flags": [{ "key": "checkout.new-flow", "tags": ["team:payments"] }], + "cases": [ + { + "name": "human-readable description", + "query": { "selected_flag_tags": [" team:payments ", "checkout"] }, + "expected": { "outcome": "applied", "selectedKeys": ["checkout.new-flow"] }, + "sdk": { "selection": { "tags": ["checkout", "team:payments"], "keyPatterns": [] } } + }, + { + "name": "tag of 201 bytes is invalid", + "query": { "selected_flag_tags": ["aaa…a"] }, + "expected": { "outcome": "ignored", "reason": "invalid_tag" }, + "sdk": { "dropped": "invalid_tag" } + }, + { + "name": "undecodable value is malformed", + "rawQuery": "selected_flag_tags=%zz", + "expected": { "outcome": "ignored", "reason": "malformed_selection" } + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `flags` | array | The flag set every case runs against: `key` (string) and `tags` (array of strings) | +| `cases[].name` | string | Unique, human-readable case name | +| `cases[].query` | object? | Decoded values per parameter, in request order, before trimming. Keys are `selected_flag_tags` and `selected_flag_key_patterns` | +| `cases[].rawQuery` | string? | An undecoded URL query string, for cases about percent-decoding and repeated parameters | +| `cases[].expected.outcome` | string | `applied` or `ignored` | +| `cases[].expected.reason` | string | Present only when `ignored`: `malformed_selection`, `too_many_values`, `invalid_key_pattern`, or `invalid_tag` | +| `cases[].expected.selectedKeys` | array | Present only when `applied`: the selected flag keys, sorted by byte order | +| `cases[].sdk` | object? | Optional SDK expectation. Present on every `query` case and absent on `rawQuery` cases. Holds exactly one of `selection` or `dropped` | +| `cases[].sdk.selection` | object | The canonical selection the SDK sends: `tags` and `keyPatterns`, each an array of strings sorted by UTF-8 bytes. Both empty means the SDK sends no selection | +| `cases[].sdk.dropped` | string | The reason the SDK drops the selection. Equal to `expected.reason` | + +Each case has exactly one of `query` or `rawQuery`. + +`sdk` is optional so that a reader written before it existed still runs every +case against `expected` and ignores `sdk`. An SDK reader runs `sdk`; a server +reader may ignore it. + +## Semantics + +Normalization, applied to each list independently: + +- Values are trimmed of leading and trailing characters with the Unicode + `White_Space` property. Other characters, such as U+FEFF and U+200B, are kept. +- Empty values are dropped, then duplicates are removed. Request order does not + matter. +- The canonical lists are sorted by UTF-8 bytes. This differs from UTF-16 code + unit order for characters outside the Basic Multilingual Plane. + +Limits, checked after normalization: + +- Each list holds at most 31 values. One list over the limit ignores the whole + selection, even when the other list is within it. +- A key pattern is at most 200 bytes, measured in UTF-8 after trimming. +- A tag is at most 200 bytes, measured in UTF-8 after trimming. One tag over the + limit ignores the whole selection, even when other tags match. + +Matching: + +- Tags match exactly and case-sensitively. A flag matches when it has any of the + selected tags. Tags are never globs. +- Key patterns match the whole key, case-sensitively. `*` matches any run of + characters, including none. Every other character is literal, including `?`, + `.`, `[`, `]`, `{`, `}`, and `\`. +- A flag is selected when it matches any selected tag or any selected key + pattern. When both lists are empty after normalization, every flag is + selected. +- A selection that matches no flag is applied and returns no flags. It is not + an error. + +## Ignored Reasons + +When a selection is ignored, the response contains every flag and the server +sets the `DD-Flag-Selection-Ignored` response header to the reason. When more +than one reason applies, the first in this table wins: + +| Precedence | Reason | When | In vectors | +|------------|--------|------|------------| +| 1 | `malformed_selection` | GET: a selection parameter value cannot be percent-decoded. POST: `flag_selection` is not an object, has a field other than `tags` or `key_patterns`, holds a list that is not an array of strings, or appears more than once | GET only | +| 2 | `too_many_values` | A list holds more than 31 values after normalization | Yes | +| 3 | `invalid_key_pattern` | A key pattern is longer than 200 bytes after trimming | Yes | +| 4 | `invalid_tag` | A tag is longer than 200 bytes after trimming | Yes | +| 5 | `tag_load_failed` | The selection uses tags and the server cannot load the tags of the configured flags | No, server state | + +A parameter that is not a selection parameter never causes `malformed_selection`, +even when it cannot be decoded. Parameter names are percent-decoded before they +are compared, so `selected%5Fflag%5Ftags` is a selection parameter. In the POST +body, JSON `null` for `flag_selection`, `tags`, or `key_patterns` means absent. + +## Running the Vectors + +Load `selection-vectors.json`, build the flag set from `flags`, and run each +case through the same parsing, validation, and matching code the endpoint uses. +For an `applied` case, compare the sorted selected keys with `selectedKeys`. For +an `ignored` case, compare the reason with `reason`. + +### GET query form + +- `query` case: either hand the decoded lists to the normalization step, or + build a query string that repeats each parameter once per value, in the listed + order, percent-encoding each value. +- `rawQuery` case: parse the string as the raw query of the request URL. + +### POST body form + +- `query` case: build the body field from the two parameters. A parameter + missing from `query` becomes `null`. Omit `flag_selection` when `query` is + empty. + + ```json + { + "flag_selection": { + "tags": "", + "key_patterns": "" + } + } + ``` + +- `rawQuery` case: skip it. JSON bodies have no percent-decoding. + +### SDK form + +For each case with `sdk`, pass the SDK's flag selection option +`{tags: query.selected_flag_tags, keyPatterns: query.selected_flag_key_patterns}` +to the SDK's canonicalization step. A parameter missing from `query` is an +omitted list. Then: + +- `sdk.selection`: compare the canonical lists the SDK would send, in order. + When both are empty, the SDK sends no selection parameters. +- `sdk.dropped`: the SDK drops the selection with this reason and sends none. + +## Tags for `ufc-config.json` + +UFC responses carry no tags, so a mock configuration endpoint that serves the +shared [`ufc-config.json`](../ufc-config.json) cannot apply a tag selection on +its own. `ufc-config-tags.json` lists the tags of some of its flags, in the same +shape as `flags` above. A flag that is not listed has no tags. + +```json +{ + "flags": [{ "key": "basic-flag", "tags": ["team:checkout", "release:summer sale"] }] +} +``` + +A mock that serves `ufc-config.json` builds the flag set from every key in +`ufc-config.json` and the tags listed here, applies the selection with the +semantics above, and returns only the selected entries of `flags`. The vectors +do not run against this flag set. + +## Consumers + +Each consumer pins a merged commit of this repository. A change to these +vectors merges here first; each consumer then moves its pin to the merged commit +in its own change. + +## Validation + +```bash +python3 ci/validate-flag-selection.py +``` + +Checks: valid JSON without duplicate keys, the fields above, exactly one input +per case, unique case names, known reasons, and `selectedKeys` that are sorted, +distinct, and present in `flags`. For `sdk`, it checks that every `query` case +has one and no `rawQuery` case does, that `sdk.selection` lists hold non-empty +strings sorted by UTF-8 bytes without duplicates, that `sdk.selection` appears +only on `applied` cases, and that `sdk.dropped` equals `expected.reason`. It +does not recompute expected outcomes. For `ufc-config-tags.json`, it checks the +same `flags` shape and that every key names a flag in `ufc-config.json`. diff --git a/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/selection-vectors.json b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/selection-vectors.json new file mode 100644 index 00000000000..2daf24b9276 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/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/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json new file mode 100644 index 00000000000..a88b7453e71 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json @@ -0,0 +1,7 @@ +{ + "flags": [ + {"key": "basic-flag", "tags": ["team:checkout", "release:summer sale"]}, + {"key": "numeric_flag", "tags": ["team:checkout"]}, + {"key": "experiment-flag", "tags": ["team:search"]} + ] +} diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py new file mode 100644 index 00000000000..63da3f2fcc5 --- /dev/null +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -0,0 +1,62 @@ +"""Server SDK flag selection against the mocked FFE agentless backend. + +Feature under test: a server SDK given a flag selection sends it on the agentless configuration request and serves +only the flags the backend returns. The mocked backend applies the selection to the shared ufc-config.json with the +shared tags in flag-selection/ufc-config-tags.json. +""" + +import time +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from utils import pytest + +from tests.parametric.conftest import APMLibrary +from utils import features, scenarios +from utils.mocked_backend import MockedBackendServer +from utils.mocked_backend.ffe import EXPECTED_DD_ENV, SELECTED_FLAG_TAGS + + +@pytest.fixture +def library_env(mocked_backend: MockedBackendServer) -> dict[str, str]: + mocked_backend.client.ffe_set_response("valid") + return { + "DD_API_KEY": scenarios.parametric.dd_api_key, + "DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": "agentless", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": mocked_backend.ffe_library_config_url, + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "1", + } + + +def _evaluate_boolean(test_library: APMLibrary, flag: str) -> dict[str, Any]: + for _ in range(10): + result = test_library.ffe_evaluate( + flag=flag, variation_type="BOOLEAN", default_value=False, targeting_key="alice", attributes={} + ) + if result.get("errorCode") != "PROVIDER_NOT_READY": + break + time.sleep(0.2) + return result + + +@scenarios.parametric +@features.feature_flags_configuration_filtering +class Test_FFE_Flag_Selection: + def test_tag_selection_loads_only_selected_flags( + self, test_library: APMLibrary, mocked_backend: MockedBackendServer + ) -> None: + assert test_library.ffe_start({"flagSelection": {"tags": [" team:checkout ", "team:checkout"]}}) + + selected = _evaluate_boolean(test_library, "basic-flag") + requests = mocked_backend.ffe_get_requests() + assert requests, "the SDK did not fetch its configuration" + assert parse_qs(urlsplit(requests[0]["path"]).query, keep_blank_values=True) == { + "dd_env": [EXPECTED_DD_ENV], + SELECTED_FLAG_TAGS: ["team:checkout"], + }, "the configuration request does not carry the canonical selection" + assert selected.get("value") is True, selected + + unselected = _evaluate_boolean(test_library, "serial-id-basic-flag") + assert unselected.get("value") is False, unselected + assert unselected.get("errorCode") == "FLAG_NOT_FOUND", unselected diff --git a/tests/test_the_test/test_mock_backend_v2.py b/tests/test_the_test/test_mock_backend_v2.py index 65e4a95ae35..128688d4e4b 100644 --- a/tests/test_the_test/test_mock_backend_v2.py +++ b/tests/test_the_test/test_mock_backend_v2.py @@ -24,7 +24,7 @@ from utils._context._scenarios.agentless_endtoend import JAVA_TRUSTSTORE_SOURCE from utils.mocked_backend import MockedBackendClient, MockedBackendServer from utils.mocked_backend._tls import generate_certificate -from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH +from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH, UFC_TAGS_FIXTURE_RELATIVE_PATH from utils.proxy.mocked_response import MockedBackendResponse @@ -323,14 +323,15 @@ def test_control_requests_are_not_recorded(tmp_path: Path): @scenarios.test_the_test def test_runs_without_the_rest_of_utils(tmp_path: Path): """The mocked backend must work with only what its container mounts (utils/mocked_backend, utils/proxy and the - FFE fixture, see MockedBackendContainer), and without pytest. + FFE fixtures, see MockedBackendContainer), and without pytest. """ repo_root = Path(__file__).parents[2] (tmp_path / "utils").mkdir() # no __init__.py: a namespace package, as in the proxy image for package in ("mocked_backend", "proxy"): (tmp_path / "utils" / package).symlink_to(repo_root / "utils" / package) - (tmp_path / UFC_FIXTURE_RELATIVE_PATH).parent.mkdir(parents=True) - (tmp_path / UFC_FIXTURE_RELATIVE_PATH).symlink_to(repo_root / UFC_FIXTURE_RELATIVE_PATH) + for fixture in (UFC_FIXTURE_RELATIVE_PATH, UFC_TAGS_FIXTURE_RELATIVE_PATH): + (tmp_path / fixture).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / fixture).symlink_to(repo_root / fixture) # Not `python -I`: third-party packages may come from PYTHONPATH (e.g. Nix). Instead, drop the repository # from sys.path, otherwise the real utils package would be imported, and this test would always pass. @@ -351,6 +352,9 @@ def test_runs_without_the_rest_of_utils(tmp_path: Path): "try:\n" " with urlopen(ffe_library_config_url(server.base_url), timeout=5) as response:\n" " assert response.status == 200, response.status\n" + " selection_url = ffe_library_config_url(server.base_url) + '&selected_flag_tags=team%3Acheckout'\n" + " with urlopen(selection_url, timeout=5) as response:\n" + " assert response.status == 200, response.status\n" "finally:\n" " server.close()\n" ) diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py new file mode 100644 index 00000000000..2ed2161000d --- /dev/null +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -0,0 +1,228 @@ +"""Unit coverage for flag selection on the mocked FFE agentless configuration endpoint.""" + +from dataclasses import dataclass +import http.client +import json +from pathlib import Path +from urllib.parse import quote, urlsplit + +import requests +from utils import pytest + +from utils import scenarios +from utils.mocked_backend import MockedBackendServer +from utils.mocked_backend._ffe_flag_selection import ( + SelectionIgnoredError, + has_selection_parameters, + parse_selection, + select_flags, +) +from utils.mocked_backend.ffe import ( + CONFIG_PATH, + CONFIG_QUERY, + FLAG_SELECTION_IGNORED_HEADER, + UFC_FIXTURE_RELATIVE_PATH, +) + +REPO_ROOT = Path(__file__).parents[2] +VECTORS = json.loads( + (REPO_ROOT / "tests/parametric/test_ffe/ffe-system-test-data/flag-selection/selection-vectors.json").read_text() +) +UFC_FLAGS = sorted(json.loads((REPO_ROOT / UFC_FIXTURE_RELATIVE_PATH).read_text())["flags"]) + + +def _encode(value: str) -> str: + # Matches the browser SDK, which also escapes !'()* beyond encodeURIComponent. + return quote(value, safe="-_.~") + + +def _vector_query(case: dict) -> str: + if "rawQuery" in case: + return case["rawQuery"] + return "&".join(f"{name}={_encode(value)}" for name, values in case["query"].items() for value in values) + + +@scenarios.test_the_test +@pytest.mark.parametrize("case", VECTORS["cases"], ids=[case["name"] for case in VECTORS["cases"]]) +def test_selection_matches_shared_vectors(case: dict) -> None: + flag_tags = {flag["key"]: flag["tags"] for flag in VECTORS["flags"]} + raw_query = _vector_query(case) + expected = case["expected"] + + if expected["outcome"] == "ignored": + with pytest.raises(SelectionIgnoredError) as error: + parse_selection(raw_query) + assert error.value.reason == expected["reason"] + return + + if not has_selection_parameters(raw_query): + assert expected["selectedKeys"] == sorted(flag_tags) + return + assert select_flags(parse_selection(raw_query), flag_tags) == expected["selectedKeys"] + + +@dataclass +class _Response: + status: int + headers: http.client.HTTPMessage + body: bytes + + def json(self) -> dict: + return json.loads(self.body) + + +def _get(server: MockedBackendServer, query: str) -> _Response: + # Not requests: it re-encodes invalid percent-escapes such as %zz, which the malformed cases must send as is. + connection = http.client.HTTPConnection(urlsplit(server.base_url).netloc, timeout=5) + try: + connection.request("GET", f"{CONFIG_PATH}?{query}") + response = connection.getresponse() + return _Response(response.status, response.headers, response.read()) + finally: + connection.close() + + +def _get_config(server: MockedBackendServer, selection_query: str = "") -> _Response: + response = _get(server, f"{CONFIG_QUERY}&{selection_query}" if selection_query else CONFIG_QUERY) + assert response.status == 200, response.body + return response + + +def _flag_keys(response: _Response) -> list[str]: + return sorted(response.json()["data"]["attributes"]["flags"]) + + +@scenarios.test_the_test +@pytest.mark.parametrize( + ("selection_query", "expected_keys"), + [ + ("selected_flag_tags=team%3Acheckout", ["basic-flag", "numeric_flag"]), + ("selected_flag_tags=release%3Asummer%20sale", ["basic-flag"]), + ("selected_flag_tags=release:summer+sale", ["basic-flag"]), + ( + "selected_flag_key_patterns=serial-id-%2A", + ["serial-id-basic-flag", "serial-id-missing-flag", "serial-id-targeting-flag", "serial-id-zero-flag"], + ), + ( + "selected_flag_tags=team%3Asearch&selected_flag_key_patterns=numeric_flag", + ["experiment-flag", "numeric_flag"], + ), + ("selected_flag_tags=no-flag-has-this-tag", []), + ], +) +def test_valid_selection_serves_only_selected_flags(selection_query: str, expected_keys: list[str]) -> None: + server = MockedBackendServer() + try: + unfiltered = _get_config(server) + response = _get_config(server, selection_query) + + assert _flag_keys(response) == expected_keys + assert FLAG_SELECTION_IGNORED_HEADER not in response.headers + assert response.headers["ETag"] != unfiltered.headers["ETag"] + assert ( + response.json()["data"]["attributes"]["environment"] + == unfiltered.json()["data"]["attributes"]["environment"] + ) + assert server.ffe_get_requests()[-1]["path"].endswith(selection_query) + finally: + server.close() + + +@scenarios.test_the_test +@pytest.mark.parametrize( + ("selection_query", "reason"), + [ + ("selected_flag_tags=%zz", "malformed_selection"), + ("&".join(f"selected_flag_tags=tag-{index}" for index in range(32)), "too_many_values"), + ("selected_flag_key_patterns=" + "a" * 201, "invalid_key_pattern"), + ("selected_flag_tags=" + "a" * 201 + "&selected_flag_tags=team%3Acheckout", "invalid_tag"), + ], +) +def test_invalid_selection_serves_every_flag_with_reason(selection_query: str, reason: str) -> None: + server = MockedBackendServer() + try: + unfiltered = _get_config(server) + response = _get_config(server, selection_query) + + assert _flag_keys(response) == UFC_FLAGS + assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == reason + assert response.headers["ETag"] == unfiltered.headers["ETag"] + finally: + server.close() + + +@scenarios.test_the_test +def test_absent_or_empty_selection_serves_every_flag_without_reason() -> None: + server = MockedBackendServer() + try: + for selection_query in ("", "selected_flag_tags=%20&selected_flag_key_patterns="): + response = _get_config(server, selection_query) + assert _flag_keys(response) == UFC_FLAGS, selection_query + assert FLAG_SELECTION_IGNORED_HEADER not in response.headers, selection_query + finally: + server.close() + + +@scenarios.test_the_test +def test_selection_does_not_relax_the_environment_query() -> None: + server = MockedBackendServer() + try: + for query in ( + "selected_flag_tags=team%3Acheckout", + "dd_env=test&dd_env=wrong&selected_flag_tags=team%3Acheckout", + ): + assert _get(server, query).status == 404, query + finally: + server.close() + + +@scenarios.test_the_test +def test_forced_ignored_reason_applies_only_to_valid_selections() -> None: + server = MockedBackendServer() + try: + server.client.ffe_set_flag_selection_ignored_reason("tag_load_failed") + + response = _get_config(server, "selected_flag_tags=team%3Acheckout") + assert _flag_keys(response) == UFC_FLAGS + assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == "tag_load_failed" + + response = _get_config(server) + assert FLAG_SELECTION_IGNORED_HEADER not in response.headers + + response = _get_config(server, "selected_flag_tags=%zz") + assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == "malformed_selection" + + server.client.ffe_set_flag_selection_ignored_reason(None) + response = _get_config(server, "selected_flag_tags=team%3Acheckout") + assert _flag_keys(response) == ["basic-flag", "numeric_flag"] + + server.client.ffe_set_flag_selection_ignored_reason("future_reason") + server.client.ffe_reset() + response = _get_config(server, "selected_flag_tags=team%3Acheckout") + assert FLAG_SELECTION_IGNORED_HEADER not in response.headers + finally: + server.close() + + +@scenarios.test_the_test +def test_forced_ignored_reason_control_rejects_invalid_bodies() -> None: + server = MockedBackendServer() + try: + for body in ({}, {"ignored_reason": ""}, {"ignored_reason": "a b"}, {"ignored_reason": 1}, {"other": None}): + response = requests.post(f"{server.base_url}/system-tests/ffe/flag-selection", json=body, timeout=5) + assert response.status_code == 400, body + finally: + server.close() + + +@scenarios.test_the_test +def test_selection_is_not_applied_to_error_responses() -> None: + server = MockedBackendServer() + try: + server.client.ffe_set_responses(["server_error", "not_modified"]) + for expected_status in (500, 304): + response = _get(server, f"{CONFIG_QUERY}&selected_flag_tags=%zz") + assert response.status == expected_status + assert FLAG_SELECTION_IGNORED_HEADER not in response.headers + finally: + server.close() diff --git a/tests/test_the_test/test_update_ffe_fixtures.py b/tests/test_the_test/test_update_ffe_fixtures.py index 8f53acd82ee..6e0c8174b2c 100644 --- a/tests/test_the_test/test_update_ffe_fixtures.py +++ b/tests/test_the_test/test_update_ffe_fixtures.py @@ -59,6 +59,26 @@ def test_copy_fixture_snapshot_rejects_symbolic_links(tmp_path: Path) -> None: update_ffe_fixtures._copy_fixture_snapshot(source, snapshot) +@scenarios.test_the_test +def test_copy_flag_selection_snapshot_imports_vectors_and_tags(tmp_path: Path) -> None: + source = tmp_path / "source" + snapshot = tmp_path / "snapshot" + (source / "flag-selection").mkdir(parents=True) + snapshot.mkdir() + (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") + (source / "flag-selection" / "ufc-config-tags.json").write_text('{"flags": []}', encoding="utf-8") + + update_ffe_fixtures._copy_flag_selection_snapshot(source, snapshot) + update_ffe_fixtures._validate_flag_selection_snapshot(snapshot) + + assert (snapshot / "flag-selection" / "selection-vectors.json").is_file() + assert (snapshot / "flag-selection" / "ufc-config-tags.json").is_file() + + (snapshot / "flag-selection" / "ufc-config-tags.json").unlink() + with pytest.raises(ValueError, match="must contain ufc-config-tags.json"): + update_ffe_fixtures._validate_flag_selection_snapshot(snapshot) + + @scenarios.test_the_test def test_validate_fixture_snapshot_counts_evaluations(tmp_path: Path) -> None: cases_directory = tmp_path / "evaluation-cases" diff --git a/utils/_context/containers.py b/utils/_context/containers.py index f2f85e3e6c6..73f4fb23084 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -27,7 +27,7 @@ from utils.docker_fixtures._core import extra_hosts_for_environment from utils.proxy.tuf import get_tuf_root_json from utils.mocked_backend import MOCKED_BACKEND_DOMAIN, MockedBackendClient, MockedBackendServer -from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH +from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH, UFC_TAGS_FIXTURE_RELATIVE_PATH from utils.proxy.ports import ProxyPorts from utils.proxy.mocked_response import ( RemoveMetaStructsSupport, @@ -807,6 +807,11 @@ def __init__(self, *, tls: bool = False) -> None: "./utils/proxy": {"bind": "/app/utils/proxy", "mode": "ro"}, # served as the FFE agentless configuration f"./{UFC_FIXTURE_RELATIVE_PATH}": {"bind": f"/app/{UFC_FIXTURE_RELATIVE_PATH}", "mode": "ro"}, + # the tags its flag selection matches against + f"./{UFC_TAGS_FIXTURE_RELATIVE_PATH}": { + "bind": f"/app/{UFC_TAGS_FIXTURE_RELATIVE_PATH}", + "mode": "ro", + }, }, # for the control endpoints, used from the host. Docker picks a free host port ports={f"{self.port}/tcp": ("127.0.0.1", None)}, diff --git a/utils/_features.py b/utils/_features.py index df80e069672..53701db2da8 100644 --- a/utils/_features.py +++ b/utils/_features.py @@ -2756,6 +2756,11 @@ def feature_flags_evaluation_telemetry_opt_out(test_object): """ return _mark_test_object(test_object, feature_id=649, owner=_Owner.ffe) + @staticmethod + def feature_flags_configuration_filtering(test_object): + """Feature Flags configuration filtering (flag selection by tags and key patterns)""" + return _mark_test_object(test_object, feature_id=NOT_REPORTED_ID, owner=_Owner.ffe) + @staticmethod def appsec_extended_data_collection(test_object): """AppSec supports extended data collection including headers and body diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index f289b45c39e..375d2131e64 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -5,6 +5,12 @@ default to JSON; use ``response_format="protobuf"`` for an independent protobuf timeline. ``mocked_backend.client.ffe_get_status()`` reports request counts, auth, ETag, response codes, and paths observed across both formats. + +The JSON configuration applies the ``selected_flag_tags`` and ``selected_flag_key_patterns`` query +parameters like production, using the shared tags in ``UFC_TAGS_FIXTURE_RELATIVE_PATH``. +``mocked_backend.client.ffe_set_flag_selection_ignored_reason(...)`` makes it ignore every valid +selection with that reason, as when production cannot load flag tags. The protobuf helpers ignore +selection. """ from __future__ import annotations @@ -21,6 +27,17 @@ from typing import TYPE_CHECKING, Literal, TypedDict, cast from urllib.parse import parse_qs, urlencode, urlparse +from ._ffe_flag_selection import ( + FLAG_SELECTION_IGNORED_HEADER, + Selection, + SelectionIgnoredError, + has_selection_parameters, + is_selection_parameter, + load_ufc_flag_tags, + parse_selection, + select_flags, + validate_forced_reason, +) from ._utils import CONTROL_URL_PREFIX @@ -120,6 +137,7 @@ def __init__(self) -> None: self.last_status_code: int | None = None self.status_codes: list[int] = [] self.requests: list[MockedBackendFFERequest] = [] + self._flag_selection_ignored_reason: str | None = None def reset(self) -> None: with self._lock: @@ -133,6 +151,7 @@ def reset(self) -> None: self.last_status_code = None self.status_codes = [] self.requests = [] + self._flag_selection_ignored_reason = None def set_responses(self, responses: object, response_format: ResponseFormat) -> None: validated_responses = validate_responses(responses, response_format) @@ -160,6 +179,15 @@ def record_request(self, headers: Mapping[str, str], path: str, response_format: responses.pop(0) return response + @property + def flag_selection_ignored_reason(self) -> str | None: + with self._lock: + return self._flag_selection_ignored_reason + + def set_flag_selection_ignored_reason(self, reason: str | None) -> None: + with self._lock: + self._flag_selection_ignored_reason = reason + def record_response(self, status_code: int) -> None: with self._lock: self.last_status_code = status_code @@ -192,8 +220,10 @@ def _get_dd_api_key(headers: Mapping[str, str]) -> str | None: return normalized.get("dd-api-key") -def _valid_ufc_bytes() -> bytes: +def _valid_ufc_bytes(selected_keys: list[str] | None = None) -> bytes: attributes = json.loads(UFC_FIXTURE_PATH.read_text()) + if selected_keys is not None: + attributes["flags"] = {key: attributes["flags"][key] for key in selected_keys} return json.dumps( { "data": { @@ -228,7 +258,29 @@ def validate_responses(responses: object, response_format: ResponseFormat = "jso return responses -def _response_for_response(response: str) -> tuple[int, bytes, dict[str, str]]: +def _flag_selection_for_query(raw_query: str, forced_reason: str | None) -> tuple[Selection | None, str | None]: + """Return the selection to apply, or None to serve every flag, and the DD-Flag-Selection-Ignored reason.""" + if not has_selection_parameters(raw_query): + return None, None + try: + selection = parse_selection(raw_query) + except SelectionIgnoredError as error: + return None, error.reason + if selection.is_empty(): + return None, None + if forced_reason is not None: + return None, forced_reason + return selection, None + + +def _has_expected_config_query(raw_query: str) -> bool: + other_pairs = [pair for pair in raw_query.split("&") if not is_selection_parameter(pair)] + return parse_qs("&".join(other_pairs), keep_blank_values=True) == {"dd_env": [EXPECTED_DD_ENV]} + + +def _response_for_response( + response: str, selection: Selection | None = None, ignored_reason: str | None = None +) -> tuple[int, bytes, dict[str, str]]: if response == "unauthorized": return HTTPStatus.UNAUTHORIZED, b"", {} if response == "malformed": @@ -238,7 +290,17 @@ def _response_for_response(response: str) -> tuple[int, bytes, dict[str, str]]: if response == "server_error": return HTTPStatus.INTERNAL_SERVER_ERROR, b"", {} if response in {"valid", "delayed_valid", "timeout"}: - return HTTPStatus.OK, _valid_ufc_bytes(), {"Content-Type": "application/json", "ETag": UFC_ETAG} + headers = {"Content-Type": "application/json", "ETag": UFC_ETAG} + if ignored_reason is not None: + headers[FLAG_SELECTION_IGNORED_HEADER] = ignored_reason + if selection is None: + return HTTPStatus.OK, _valid_ufc_bytes(), headers + flags = json.loads(UFC_FIXTURE_PATH.read_text())["flags"] + flag_tags = load_ufc_flag_tags(REPO_ROOT) + selected_keys = select_flags(selection, {key: flag_tags.get(key, []) for key in flags}) + # A 304 must not confirm a body built for another selection. + headers["ETag"] = f'{UFC_ETAG[:-1]}-{selection.digest()}"' + return HTTPStatus.OK, _valid_ufc_bytes(selected_keys), headers raise ValueError(f"Unsupported JSON response: {response}") @@ -282,6 +344,7 @@ class MockedBackendServerFfeMixin(_Base): # - GET /system-tests/ffe/status # - POST /system-tests/ffe/responses # - POST /system-tests/ffe/reset + # - POST /system-tests/ffe/flag-selection def _ffe_configure(self) -> None: self._ffe_state = _MockedBackendFFEState() @@ -298,6 +361,7 @@ def _ffe_configure(self) -> None: self.add_handler("GET", f"{self._ffe_control_path}/status", self._ffe_handle_status) self.add_handler("POST", f"{self._ffe_control_path}/responses", self._ffe_handle_control_responses) self.add_handler("POST", f"{self._ffe_control_path}/reset", self._ffe_handle_control_reset) + self.add_handler("POST", f"{self._ffe_control_path}/flag-selection", self._ffe_handle_control_flag_selection) @property def ffe_library_base_url(self) -> str: @@ -335,7 +399,7 @@ def _ffe_handle_config( return self._ffe_handle_rules_config(request, _) parsed = urlparse(request.path) - if parse_qs(parsed.query, keep_blank_values=True) == {"dd_env": [EXPECTED_DD_ENV]}: + if _has_expected_config_query(parsed.query): return self._ffe_handle_config_exec(request) return request.write_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) @@ -347,7 +411,10 @@ def _ffe_handle_config_exec(self, request: MockedBackendRequestHandler) -> tuple if response in {"delayed_valid", "timeout"}: time.sleep(TIMEOUT_RESPONSE_SECONDS if response == "timeout" else DELAYED_RESPONSE_SECONDS) - status_code, body, headers = _response_for_response(response) + selection, ignored_reason = _flag_selection_for_query( + urlparse(request.path).query, self._ffe_state.flag_selection_ignored_reason + ) + status_code, body, headers = _response_for_response(response, selection, ignored_reason) self._ffe_state.record_response(status_code) with contextlib.suppress(BrokenPipeError, ConnectionResetError): request.send_response(status_code) @@ -428,6 +495,18 @@ def _ffe_handle_control_responses( return request.write_json(HTTPStatus.OK, self._ffe_state.status()) + def _ffe_handle_control_flag_selection( + self, request: MockedBackendRequestHandler, payload: bytes | dict | None + ) -> tuple[Mapping, bytes]: + if not isinstance(payload, dict) or set(payload) != {"ignored_reason"}: + return request.write_json(HTTPStatus.BAD_REQUEST, {"error": "control body must contain ignored_reason"}) + try: + reason = validate_forced_reason(payload["ignored_reason"]) + except ValueError as error: + return request.write_json(HTTPStatus.BAD_REQUEST, {"error": str(error)}) + self._ffe_state.set_flag_selection_ignored_reason(reason) + return request.write_json(HTTPStatus.OK, {"ignored_reason": reason}) + def _ffe_handle_control_reset( self, request: MockedBackendRequestHandler, _: bytes | dict | None ) -> tuple[Mapping, bytes]: diff --git a/utils/mocked_backend/_client.py b/utils/mocked_backend/_client.py index 707e6a7aacc..82386cf7d22 100644 --- a/utils/mocked_backend/_client.py +++ b/utils/mocked_backend/_client.py @@ -43,6 +43,16 @@ def ffe_set_responses(self, responses: object, *, response_format: ResponseForma ) response.raise_for_status() + def ffe_set_flag_selection_ignored_reason(self, reason: str | None) -> None: + """Ignore every valid flag selection with this DD-Flag-Selection-Ignored reason; None applies them again""" + response = requests.post( + f"{self.base_url}{_FFE_CONTROL_PATH}/flag-selection", + json={"ignored_reason": reason}, + timeout=5, + verify=self.verify, + ) + response.raise_for_status() + def set_mocked_responses(self, responses: Sequence[MockedBackendResponse]) -> None: """Replace the mocked responses set by tests, like MockedBackendResponse.send() does with the proxy""" response = requests.put( diff --git a/utils/mocked_backend/_ffe_flag_selection.py b/utils/mocked_backend/_ffe_flag_selection.py new file mode 100644 index 00000000000..08cad3909d6 --- /dev/null +++ b/utils/mocked_backend/_ffe_flag_selection.py @@ -0,0 +1,180 @@ +"""Flag selection for the mocked FFE rules-based configuration endpoint. + +Mirrors the production endpoint: a valid selection serves only the flags it selects, and a selection that cannot be +applied serves every flag and names the reason in the DD-Flag-Selection-Ignored response header. The shared +ffe-system-test-data vectors in flag-selection/selection-vectors.json define the semantics. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import re +from typing import TYPE_CHECKING +from urllib.parse import unquote_to_bytes, urlencode + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + from pathlib import Path + + +SELECTED_FLAG_TAGS = "selected_flag_tags" +SELECTED_FLAG_KEY_PATTERNS = "selected_flag_key_patterns" +FLAG_SELECTION_IGNORED_HEADER = "DD-Flag-Selection-Ignored" +MAX_VALUES = 31 +MAX_VALUE_BYTES = 200 +FORCED_REASON_PATTERN = re.compile(r"[A-Za-z0-9_.-]{1,64}") +UFC_TAGS_FIXTURE_RELATIVE_PATH = "tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json" + +# The Unicode White_Space property. str.strip() would also remove U+001C..U+001F, which production keeps. +_WHITE_SPACE = "".join( + chr(code_point) + for code_point in ( + *range(0x09, 0x0E), + 0x20, + 0x85, + 0xA0, + 0x1680, + *range(0x2000, 0x200B), + 0x2028, + 0x2029, + 0x202F, + 0x205F, + 0x3000, + ) +) +_INVALID_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") + + +class SelectionIgnoredError(ValueError): + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +@dataclass(frozen=True) +class Selection: + tags: tuple[str, ...] + key_patterns: tuple[str, ...] + + def is_empty(self) -> bool: + return not self.tags and not self.key_patterns + + def digest(self) -> str: + canonical = urlencode( + [(SELECTED_FLAG_KEY_PATTERNS, value) for value in self.key_patterns] + + [(SELECTED_FLAG_TAGS, value) for value in self.tags] + ) + return hashlib.sha256(canonical.encode("utf-8", "surrogateescape")).hexdigest()[:16] + + +def _raw_bytes(value: str) -> bytes: + # http.server decodes the request line as ISO-8859-1, so this restores the bytes on the wire. + try: + return value.encode("latin-1") + except UnicodeEncodeError: + return value.encode("utf-8") + + +def _query_unescape(value: str) -> str: + """Decode like Go's url.QueryUnescape: '+' is a space and a '%' must start a two-digit hex escape.""" + if _INVALID_ESCAPE.search(value): + raise ValueError("invalid percent-escape") + return unquote_to_bytes(_raw_bytes(value.replace("+", " "))).decode("utf-8", "surrogateescape") + + +def _utf8_length(value: str) -> int: + return len(value.encode("utf-8", "surrogateescape")) + + +def _parameter_name(pair: str) -> str | None: + try: + return _query_unescape(pair.partition("=")[0]) + except ValueError: + return None + + +def is_selection_parameter(pair: str) -> bool: + return _parameter_name(pair) in {SELECTED_FLAG_TAGS, SELECTED_FLAG_KEY_PATTERNS} + + +def has_selection_parameters(raw_query: str) -> bool: + return any(is_selection_parameter(pair) for pair in raw_query.split("&")) + + +def _normalize(values: Iterable[str]) -> tuple[str, ...]: + trimmed = {value.strip(_WHITE_SPACE) for value in values} + trimmed.discard("") + return tuple(sorted(trimmed, key=lambda value: value.encode("utf-8", "surrogateescape"))) + + +def parse_selection(raw_query: str) -> Selection: + """Parse the raw query as http.server receives it. + + Raise SelectionIgnoredError with the first reason, in the documented precedence, that ignores the selection. + """ + raw: dict[str, list[str]] = {SELECTED_FLAG_TAGS: [], SELECTED_FLAG_KEY_PATTERNS: []} + for pair in raw_query.split("&"): + name = _parameter_name(pair) + if name not in raw: + continue + try: + raw[name].append(_query_unescape(pair.partition("=")[2])) + except ValueError: + raise SelectionIgnoredError("malformed_selection") from None + + selection = Selection( + tags=_normalize(raw[SELECTED_FLAG_TAGS]), key_patterns=_normalize(raw[SELECTED_FLAG_KEY_PATTERNS]) + ) + if len(selection.tags) > MAX_VALUES or len(selection.key_patterns) > MAX_VALUES: + raise SelectionIgnoredError("too_many_values") + if any(_utf8_length(pattern) > MAX_VALUE_BYTES for pattern in selection.key_patterns): + raise SelectionIgnoredError("invalid_key_pattern") + if any(_utf8_length(tag) > MAX_VALUE_BYTES for tag in selection.tags): + raise SelectionIgnoredError("invalid_tag") + return selection + + +def match_key_pattern(pattern: str, key: str) -> bool: + """'*' matches any run of characters, including none; every other character is literal.""" + parts = pattern.split("*") + if len(parts) == 1: + return pattern == key + first, *middle, last = parts + if len(key) < len(first) + len(last) or not key.startswith(first) or not key.endswith(last): + return False + # Leftmost matching of the middle literals is enough: a later occurrence leaves less room for the rest. + remaining = key[len(first) : len(key) - len(last)] + for part in middle: + index = remaining.find(part) + if index < 0: + return False + remaining = remaining[index + len(part) :] + return True + + +def select_flags(selection: Selection, flag_tags: Mapping[str, Sequence[str]]) -> list[str]: + """Return the sorted keys selected among flag_tags, which maps every served flag key to its tags.""" + wanted = set(selection.tags) + return sorted( + key + for key, tags in flag_tags.items() + if selection.is_empty() + or not wanted.isdisjoint(tags) + or any(match_key_pattern(pattern, key) for pattern in selection.key_patterns) + ) + + +def load_ufc_flag_tags(repository_root: Path) -> dict[str, list[str]]: + """Tags of the shared UFC flags. A flag that is not listed has no tags.""" + fixture = json.loads((repository_root / UFC_TAGS_FIXTURE_RELATIVE_PATH).read_text(encoding="utf-8")) + return {flag["key"]: list(flag["tags"]) for flag in fixture["flags"]} + + +def validate_forced_reason(reason: object) -> str | None: + if reason is None: + return None + if not isinstance(reason, str) or FORCED_REASON_PATTERN.fullmatch(reason) is None: + raise ValueError("ignored_reason must be null or a token of letters, digits, '_', '.', or '-'") + return reason diff --git a/utils/mocked_backend/ffe.py b/utils/mocked_backend/ffe.py index a9281b222e2..ecdd2dfbdc0 100644 --- a/utils/mocked_backend/ffe.py +++ b/utils/mocked_backend/ffe.py @@ -1,3 +1,9 @@ +from ._ffe_flag_selection import ( + FLAG_SELECTION_IGNORED_HEADER, + SELECTED_FLAG_KEY_PATTERNS, + SELECTED_FLAG_TAGS, + UFC_TAGS_FIXTURE_RELATIVE_PATH, +) from ._backend_ffe import ( CONFIG_PATH, CONFIG_QUERY, @@ -16,10 +22,14 @@ "CONFIG_PATH", "CONFIG_QUERY", "EXPECTED_DD_ENV", + "FLAG_SELECTION_IGNORED_HEADER", "RESPONSE_SECRET", "RULES_CONFIG_PATH", + "SELECTED_FLAG_KEY_PATTERNS", + "SELECTED_FLAG_TAGS", "UFC_FIXTURE_RELATIVE_PATH", "UFC_RESPONSE_TYPE", + "UFC_TAGS_FIXTURE_RELATIVE_PATH", "MockedBackendFFEStatus", "ResponseFormat", "ffe_library_config_url", diff --git a/utils/scripts/update_ffe_fixtures.py b/utils/scripts/update_ffe_fixtures.py index e7f3b95f484..fc303c3fb43 100644 --- a/utils/scripts/update_ffe_fixtures.py +++ b/utils/scripts/update_ffe_fixtures.py @@ -161,6 +161,29 @@ def _set_github_outputs(source_commit: str, fixture_count: int, *, changed: bool output_file.write(f"changed={str(changed).lower()}\n") +def _copy_flag_selection_snapshot(source: Path, snapshot: Path) -> None: + # The mocked backend applies flag selection to ufc-config.json with these vectors' semantics and tags. + flag_selection = source / "flag-selection" + if flag_selection.exists() or flag_selection.is_symlink(): + _copy_entry(flag_selection, snapshot / flag_selection.name) + + +def _validate_flag_selection_snapshot(snapshot: Path) -> None: + directory = snapshot / "flag-selection" + if not directory.exists(): + return + for name, fields in (("selection-vectors.json", {"flags", "cases"}), ("ufc-config-tags.json", {"flags"})): + path = directory / name + if not path.is_file(): + msg = f"FFE flag-selection must contain {name}" + raise ValueError(msg) + with path.open(encoding="utf-8") as fixture_file: + content = json.load(fixture_file) + if not isinstance(content, dict) or set(content) != fields: + msg = f"{path.name} must contain only {', '.join(sorted(fields))}" + raise ValueError(msg) + + def update_fixtures(fixture_ref: str) -> bool: """Update the snapshot and return whether its allowed contents changed.""" _validate_fixture_ref(fixture_ref) @@ -179,7 +202,9 @@ def update_fixtures(fixture_ref: str) -> bool: source_commit = _run_git(source, "rev-parse", "HEAD") _copy_fixture_snapshot(source, snapshot) + _copy_flag_selection_snapshot(source, snapshot) fixture_count = _validate_fixture_snapshot(snapshot) + _validate_flag_selection_snapshot(snapshot) changed = not _have_same_contents(snapshot, FIXTURE_DESTINATION) if changed: From 32323a3c19a131470e6698f2de37c9bfe3b3cbc3 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Mon, 2 Nov 2026 19:04:54 +0000 Subject: [PATCH 02/10] test(ffe): vendor only the selection vectors and UFC tags The fixture sync copies flag-selection/selection-vectors.json and flag-selection/ufc-config-tags.json instead of the whole directory, so the upstream README stays out of the snapshot. The copy stays outside _copy_fixture_snapshot. --- .../flag-selection/README.md | 200 ------------------ .../test_the_test/test_update_ffe_fixtures.py | 52 ++++- utils/scripts/update_ffe_fixtures.py | 27 ++- 3 files changed, 63 insertions(+), 216 deletions(-) delete mode 100644 tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md diff --git a/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md deleted file mode 100644 index 73c8e6c90e4..00000000000 --- a/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/README.md +++ /dev/null @@ -1,200 +0,0 @@ -# Flag Selection Vectors (`flag-selection/`) - -`selection-vectors.json` defines how a flag configuration endpoint narrows its -response to the flags an SDK selected, and how an SDK canonicalizes the -selection it sends. Every server-side implementation and every SDK that supports -flag selection runs these cases, so they agree on matching, normalization, -limits, and the reason reported when a selection is ignored. - -Each case carries two kinds of expectation: - -- `expected`: what the server does with the selection in the request. -- `sdk` (optional): the canonical selection an SDK sends for the same input, or - the reason the SDK drops it. - -Two request forms carry the same selection: - -- GET rules-based configuration endpoints read the `selected_flag_tags` and - `selected_flag_key_patterns` query parameters. Each parameter may repeat. -- `POST /precompute-assignments` reads a `flag_selection` body field shaped as - `{"tags": [...], "key_patterns": [...]}`. - -The limits come from the UFC Tag Filtering RFC. - -## Format - -```json -{ - "flags": [{ "key": "checkout.new-flow", "tags": ["team:payments"] }], - "cases": [ - { - "name": "human-readable description", - "query": { "selected_flag_tags": [" team:payments ", "checkout"] }, - "expected": { "outcome": "applied", "selectedKeys": ["checkout.new-flow"] }, - "sdk": { "selection": { "tags": ["checkout", "team:payments"], "keyPatterns": [] } } - }, - { - "name": "tag of 201 bytes is invalid", - "query": { "selected_flag_tags": ["aaa…a"] }, - "expected": { "outcome": "ignored", "reason": "invalid_tag" }, - "sdk": { "dropped": "invalid_tag" } - }, - { - "name": "undecodable value is malformed", - "rawQuery": "selected_flag_tags=%zz", - "expected": { "outcome": "ignored", "reason": "malformed_selection" } - } - ] -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `flags` | array | The flag set every case runs against: `key` (string) and `tags` (array of strings) | -| `cases[].name` | string | Unique, human-readable case name | -| `cases[].query` | object? | Decoded values per parameter, in request order, before trimming. Keys are `selected_flag_tags` and `selected_flag_key_patterns` | -| `cases[].rawQuery` | string? | An undecoded URL query string, for cases about percent-decoding and repeated parameters | -| `cases[].expected.outcome` | string | `applied` or `ignored` | -| `cases[].expected.reason` | string | Present only when `ignored`: `malformed_selection`, `too_many_values`, `invalid_key_pattern`, or `invalid_tag` | -| `cases[].expected.selectedKeys` | array | Present only when `applied`: the selected flag keys, sorted by byte order | -| `cases[].sdk` | object? | Optional SDK expectation. Present on every `query` case and absent on `rawQuery` cases. Holds exactly one of `selection` or `dropped` | -| `cases[].sdk.selection` | object | The canonical selection the SDK sends: `tags` and `keyPatterns`, each an array of strings sorted by UTF-8 bytes. Both empty means the SDK sends no selection | -| `cases[].sdk.dropped` | string | The reason the SDK drops the selection. Equal to `expected.reason` | - -Each case has exactly one of `query` or `rawQuery`. - -`sdk` is optional so that a reader written before it existed still runs every -case against `expected` and ignores `sdk`. An SDK reader runs `sdk`; a server -reader may ignore it. - -## Semantics - -Normalization, applied to each list independently: - -- Values are trimmed of leading and trailing characters with the Unicode - `White_Space` property. Other characters, such as U+FEFF and U+200B, are kept. -- Empty values are dropped, then duplicates are removed. Request order does not - matter. -- The canonical lists are sorted by UTF-8 bytes. This differs from UTF-16 code - unit order for characters outside the Basic Multilingual Plane. - -Limits, checked after normalization: - -- Each list holds at most 31 values. One list over the limit ignores the whole - selection, even when the other list is within it. -- A key pattern is at most 200 bytes, measured in UTF-8 after trimming. -- A tag is at most 200 bytes, measured in UTF-8 after trimming. One tag over the - limit ignores the whole selection, even when other tags match. - -Matching: - -- Tags match exactly and case-sensitively. A flag matches when it has any of the - selected tags. Tags are never globs. -- Key patterns match the whole key, case-sensitively. `*` matches any run of - characters, including none. Every other character is literal, including `?`, - `.`, `[`, `]`, `{`, `}`, and `\`. -- A flag is selected when it matches any selected tag or any selected key - pattern. When both lists are empty after normalization, every flag is - selected. -- A selection that matches no flag is applied and returns no flags. It is not - an error. - -## Ignored Reasons - -When a selection is ignored, the response contains every flag and the server -sets the `DD-Flag-Selection-Ignored` response header to the reason. When more -than one reason applies, the first in this table wins: - -| Precedence | Reason | When | In vectors | -|------------|--------|------|------------| -| 1 | `malformed_selection` | GET: a selection parameter value cannot be percent-decoded. POST: `flag_selection` is not an object, has a field other than `tags` or `key_patterns`, holds a list that is not an array of strings, or appears more than once | GET only | -| 2 | `too_many_values` | A list holds more than 31 values after normalization | Yes | -| 3 | `invalid_key_pattern` | A key pattern is longer than 200 bytes after trimming | Yes | -| 4 | `invalid_tag` | A tag is longer than 200 bytes after trimming | Yes | -| 5 | `tag_load_failed` | The selection uses tags and the server cannot load the tags of the configured flags | No, server state | - -A parameter that is not a selection parameter never causes `malformed_selection`, -even when it cannot be decoded. Parameter names are percent-decoded before they -are compared, so `selected%5Fflag%5Ftags` is a selection parameter. In the POST -body, JSON `null` for `flag_selection`, `tags`, or `key_patterns` means absent. - -## Running the Vectors - -Load `selection-vectors.json`, build the flag set from `flags`, and run each -case through the same parsing, validation, and matching code the endpoint uses. -For an `applied` case, compare the sorted selected keys with `selectedKeys`. For -an `ignored` case, compare the reason with `reason`. - -### GET query form - -- `query` case: either hand the decoded lists to the normalization step, or - build a query string that repeats each parameter once per value, in the listed - order, percent-encoding each value. -- `rawQuery` case: parse the string as the raw query of the request URL. - -### POST body form - -- `query` case: build the body field from the two parameters. A parameter - missing from `query` becomes `null`. Omit `flag_selection` when `query` is - empty. - - ```json - { - "flag_selection": { - "tags": "", - "key_patterns": "" - } - } - ``` - -- `rawQuery` case: skip it. JSON bodies have no percent-decoding. - -### SDK form - -For each case with `sdk`, pass the SDK's flag selection option -`{tags: query.selected_flag_tags, keyPatterns: query.selected_flag_key_patterns}` -to the SDK's canonicalization step. A parameter missing from `query` is an -omitted list. Then: - -- `sdk.selection`: compare the canonical lists the SDK would send, in order. - When both are empty, the SDK sends no selection parameters. -- `sdk.dropped`: the SDK drops the selection with this reason and sends none. - -## Tags for `ufc-config.json` - -UFC responses carry no tags, so a mock configuration endpoint that serves the -shared [`ufc-config.json`](../ufc-config.json) cannot apply a tag selection on -its own. `ufc-config-tags.json` lists the tags of some of its flags, in the same -shape as `flags` above. A flag that is not listed has no tags. - -```json -{ - "flags": [{ "key": "basic-flag", "tags": ["team:checkout", "release:summer sale"] }] -} -``` - -A mock that serves `ufc-config.json` builds the flag set from every key in -`ufc-config.json` and the tags listed here, applies the selection with the -semantics above, and returns only the selected entries of `flags`. The vectors -do not run against this flag set. - -## Consumers - -Each consumer pins a merged commit of this repository. A change to these -vectors merges here first; each consumer then moves its pin to the merged commit -in its own change. - -## Validation - -```bash -python3 ci/validate-flag-selection.py -``` - -Checks: valid JSON without duplicate keys, the fields above, exactly one input -per case, unique case names, known reasons, and `selectedKeys` that are sorted, -distinct, and present in `flags`. For `sdk`, it checks that every `query` case -has one and no `rawQuery` case does, that `sdk.selection` lists hold non-empty -strings sorted by UTF-8 bytes without duplicates, that `sdk.selection` appears -only on `applied` cases, and that `sdk.dropped` equals `expected.reason`. It -does not recompute expected outcomes. For `ufc-config-tags.json`, it checks the -same `flags` shape and that every key names a flag in `ufc-config.json`. diff --git a/tests/test_the_test/test_update_ffe_fixtures.py b/tests/test_the_test/test_update_ffe_fixtures.py index 6e0c8174b2c..6b8a223cb78 100644 --- a/tests/test_the_test/test_update_ffe_fixtures.py +++ b/tests/test_the_test/test_update_ffe_fixtures.py @@ -60,23 +60,65 @@ def test_copy_fixture_snapshot_rejects_symbolic_links(tmp_path: Path) -> None: @scenarios.test_the_test -def test_copy_flag_selection_snapshot_imports_vectors_and_tags(tmp_path: Path) -> None: +def test_copy_flag_selection_snapshot_imports_only_vectors_and_tags(tmp_path: Path) -> None: source = tmp_path / "source" snapshot = tmp_path / "snapshot" (source / "flag-selection").mkdir(parents=True) snapshot.mkdir() (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") (source / "flag-selection" / "ufc-config-tags.json").write_text('{"flags": []}', encoding="utf-8") + (source / "flag-selection" / "README.md").write_text("upstream documentation", encoding="utf-8") update_ffe_fixtures._copy_flag_selection_snapshot(source, snapshot) update_ffe_fixtures._validate_flag_selection_snapshot(snapshot) - assert (snapshot / "flag-selection" / "selection-vectors.json").is_file() - assert (snapshot / "flag-selection" / "ufc-config-tags.json").is_file() + assert sorted(path.name for path in (snapshot / "flag-selection").iterdir()) == [ + "selection-vectors.json", + "ufc-config-tags.json", + ] + + +@scenarios.test_the_test +def test_copy_flag_selection_snapshot_skips_absent_directory(tmp_path: Path) -> None: + (tmp_path / "source").mkdir() + (tmp_path / "snapshot").mkdir() + + update_ffe_fixtures._copy_flag_selection_snapshot(tmp_path / "source", tmp_path / "snapshot") + update_ffe_fixtures._validate_flag_selection_snapshot(tmp_path / "snapshot") + + assert not (tmp_path / "snapshot" / "flag-selection").exists() + + +@scenarios.test_the_test +def test_copy_flag_selection_snapshot_requires_tags(tmp_path: Path) -> None: + source = tmp_path / "source" + (source / "flag-selection").mkdir(parents=True) + (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") - (snapshot / "flag-selection" / "ufc-config-tags.json").unlink() with pytest.raises(ValueError, match="must contain ufc-config-tags.json"): - update_ffe_fixtures._validate_flag_selection_snapshot(snapshot) + update_ffe_fixtures._copy_flag_selection_snapshot(source, tmp_path / "snapshot") + + +@scenarios.test_the_test +def test_copy_flag_selection_snapshot_rejects_symbolic_link_directory(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + target.mkdir() + (source / "flag-selection").symlink_to(target, target_is_directory=True) + + with pytest.raises(ValueError, match="Refusing to copy symbolic link"): + update_ffe_fixtures._copy_flag_selection_snapshot(source, tmp_path / "snapshot") + + +@scenarios.test_the_test +def test_validate_flag_selection_snapshot_rejects_unexpected_fields(tmp_path: Path) -> None: + (tmp_path / "flag-selection").mkdir() + (tmp_path / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") + (tmp_path / "flag-selection" / "ufc-config-tags.json").write_text('{"flags": [], "extra": 1}', encoding="utf-8") + + with pytest.raises(ValueError, match="ufc-config-tags.json must contain only flags"): + update_ffe_fixtures._validate_flag_selection_snapshot(tmp_path) @scenarios.test_the_test diff --git a/utils/scripts/update_ffe_fixtures.py b/utils/scripts/update_ffe_fixtures.py index fc303c3fb43..cfff1b0203d 100644 --- a/utils/scripts/update_ffe_fixtures.py +++ b/utils/scripts/update_ffe_fixtures.py @@ -14,6 +14,8 @@ FIXTURE_DESTINATION = REPOSITORY_ROOT / "tests" / "parametric" / "test_ffe" / "ffe-system-test-data" FIXTURE_REPOSITORY = "https://github.com/DataDog/ffe-system-test-data.git" FIXTURE_REF_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") +SELECTION_VECTORS_PATH = Path("flag-selection/selection-vectors.json") +SELECTION_UFC_TAGS_PATH = Path("flag-selection/ufc-config-tags.json") def _validate_fixture_ref(fixture_ref: str) -> None: @@ -163,21 +165,24 @@ def _set_github_outputs(source_commit: str, fixture_count: int, *, changed: bool def _copy_flag_selection_snapshot(source: Path, snapshot: Path) -> None: # The mocked backend applies flag selection to ufc-config.json with these vectors' semantics and tags. - flag_selection = source / "flag-selection" - if flag_selection.exists() or flag_selection.is_symlink(): - _copy_entry(flag_selection, snapshot / flag_selection.name) + directory = source / SELECTION_VECTORS_PATH.parent + if directory.is_symlink(): + msg = f"Refusing to copy symbolic link from FFE fixture repository: {directory}" + raise ValueError(msg) + if not directory.exists(): + return + for path in (SELECTION_VECTORS_PATH, SELECTION_UFC_TAGS_PATH): + if not (source / path).exists() and not (source / path).is_symlink(): + msg = f"FFE flag-selection must contain {path.name}" + raise ValueError(msg) + _copy_entry(source / path, snapshot / path) def _validate_flag_selection_snapshot(snapshot: Path) -> None: - directory = snapshot / "flag-selection" - if not directory.exists(): + if not (snapshot / SELECTION_VECTORS_PATH.parent).exists(): return - for name, fields in (("selection-vectors.json", {"flags", "cases"}), ("ufc-config-tags.json", {"flags"})): - path = directory / name - if not path.is_file(): - msg = f"FFE flag-selection must contain {name}" - raise ValueError(msg) - with path.open(encoding="utf-8") as fixture_file: + for path, fields in ((SELECTION_VECTORS_PATH, {"flags", "cases"}), (SELECTION_UFC_TAGS_PATH, {"flags"})): + with (snapshot / path).open(encoding="utf-8") as fixture_file: content = json.load(fixture_file) if not isinstance(content, dict) or set(content) != fields: msg = f"{path.name} must contain only {', '.join(sorted(fields))}" From c3aad38d7d7269eb4ce5e4e2d6efa805b598ed9a Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Mon, 2 Nov 2026 19:50:06 +0000 Subject: [PATCH 03/10] test(ffe): serve selections with non-UTF-8 values in the mocked backend A valid selection whose value decodes to bytes that are not UTF-8, such as selected_flag_tags=%85, made the selection ETag digest raise, so the mock dropped the connection. ffe-service applies such a selection. The digest now hashes the values' wire bytes. The flag selection test also waits while the provider reports no configuration through providerState.hasConfig, like the other FFE tests. --- .../parametric/test_ffe/test_flag_selection.py | 9 ++++++++- .../test_mock_ffe_flag_selection.py | 3 ++- utils/mocked_backend/_ffe_flag_selection.py | 18 ++++++++++-------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py index 63da3f2fcc5..a6f20a1bb34 100644 --- a/tests/parametric/test_ffe/test_flag_selection.py +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -29,12 +29,19 @@ def library_env(mocked_backend: MockedBackendServer) -> dict[str, str]: } +def _is_waiting_for_config(result: dict[str, Any]) -> bool: + provider_state = result.get("providerState") + return result.get("errorCode") == "PROVIDER_NOT_READY" or ( + isinstance(provider_state, dict) and provider_state.get("hasConfig") is False + ) + + def _evaluate_boolean(test_library: APMLibrary, flag: str) -> dict[str, Any]: for _ in range(10): result = test_library.ffe_evaluate( flag=flag, variation_type="BOOLEAN", default_value=False, targeting_key="alice", attributes={} ) - if result.get("errorCode") != "PROVIDER_NOT_READY": + if not _is_waiting_for_config(result): break time.sleep(0.2) return result diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py index 2ed2161000d..1b85355df55 100644 --- a/tests/test_the_test/test_mock_ffe_flag_selection.py +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -32,7 +32,7 @@ def _encode(value: str) -> str: - # Matches the browser SDK, which also escapes !'()* beyond encodeURIComponent. + # Escape every reserved character, so '+', '%', '&', '=' and '*' in a value reach the parser literally. return quote(value, safe="-_.~") @@ -108,6 +108,7 @@ def _flag_keys(response: _Response) -> list[str]: ["experiment-flag", "numeric_flag"], ), ("selected_flag_tags=no-flag-has-this-tag", []), + ("selected_flag_tags=%85", []), ], ) def test_valid_selection_serves_only_selected_flags(selection_query: str, expected_keys: list[str]) -> None: diff --git a/utils/mocked_backend/_ffe_flag_selection.py b/utils/mocked_backend/_ffe_flag_selection.py index 08cad3909d6..751104831ff 100644 --- a/utils/mocked_backend/_ffe_flag_selection.py +++ b/utils/mocked_backend/_ffe_flag_selection.py @@ -22,6 +22,7 @@ SELECTED_FLAG_TAGS = "selected_flag_tags" SELECTED_FLAG_KEY_PATTERNS = "selected_flag_key_patterns" FLAG_SELECTION_IGNORED_HEADER = "DD-Flag-Selection-Ignored" +# Limits from flag-selection/README.md in DataDog/ffe-system-test-data. MAX_VALUES = 31 MAX_VALUE_BYTES = 200 FORCED_REASON_PATTERN = re.compile(r"[A-Za-z0-9_.-]{1,64}") @@ -63,10 +64,10 @@ def is_empty(self) -> bool: def digest(self) -> str: canonical = urlencode( - [(SELECTED_FLAG_KEY_PATTERNS, value) for value in self.key_patterns] - + [(SELECTED_FLAG_TAGS, value) for value in self.tags] + [(SELECTED_FLAG_KEY_PATTERNS, _utf8(value)) for value in self.key_patterns] + + [(SELECTED_FLAG_TAGS, _utf8(value)) for value in self.tags] ) - return hashlib.sha256(canonical.encode("utf-8", "surrogateescape")).hexdigest()[:16] + return hashlib.sha256(canonical.encode()).hexdigest()[:16] def _raw_bytes(value: str) -> bytes: @@ -84,8 +85,9 @@ def _query_unescape(value: str) -> str: return unquote_to_bytes(_raw_bytes(value.replace("+", " "))).decode("utf-8", "surrogateescape") -def _utf8_length(value: str) -> int: - return len(value.encode("utf-8", "surrogateescape")) +def _utf8(value: str) -> bytes: + """The value's bytes on the wire, including bytes that are not valid UTF-8.""" + return value.encode("utf-8", "surrogateescape") def _parameter_name(pair: str) -> str | None: @@ -106,7 +108,7 @@ def has_selection_parameters(raw_query: str) -> bool: def _normalize(values: Iterable[str]) -> tuple[str, ...]: trimmed = {value.strip(_WHITE_SPACE) for value in values} trimmed.discard("") - return tuple(sorted(trimmed, key=lambda value: value.encode("utf-8", "surrogateescape"))) + return tuple(sorted(trimmed, key=_utf8)) def parse_selection(raw_query: str) -> Selection: @@ -129,9 +131,9 @@ def parse_selection(raw_query: str) -> Selection: ) if len(selection.tags) > MAX_VALUES or len(selection.key_patterns) > MAX_VALUES: raise SelectionIgnoredError("too_many_values") - if any(_utf8_length(pattern) > MAX_VALUE_BYTES for pattern in selection.key_patterns): + if any(len(_utf8(pattern)) > MAX_VALUE_BYTES for pattern in selection.key_patterns): raise SelectionIgnoredError("invalid_key_pattern") - if any(_utf8_length(tag) > MAX_VALUE_BYTES for tag in selection.tags): + if any(len(_utf8(tag)) > MAX_VALUE_BYTES for tag in selection.tags): raise SelectionIgnoredError("invalid_tag") return selection From 10b7bdc3c45c58a7583062146ea76f98266ca5bc Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Mon, 2 Nov 2026 22:02:13 +0000 Subject: [PATCH 04/10] test(ffe): send the flag selection in its own ffe_start field The PHP parametric app loads ffe_start's configuration as a UFC, so a selection nested in it would collide with tests that pass a UFC there. ffe_start now takes flag_selection and sends it as its own field, as is. The test app passes it untouched to the SDK's provider option; only the SDK canonicalizes and validates it. --- tests/parametric/test_ffe/test_flag_selection.py | 5 ++++- .../_test_clients/_test_client_parametric.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py index a6f20a1bb34..a9385814129 100644 --- a/tests/parametric/test_ffe/test_flag_selection.py +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -3,6 +3,9 @@ Feature under test: a server SDK given a flag selection sends it on the agentless configuration request and serves only the flags the backend returns. The mocked backend applies the selection to the shared ufc-config.json with the shared tags in flag-selection/ufc-config-tags.json. + +The selection reaches the SDK as written, through ffe_start's flag_selection field, so the canonical selection on the +request is the SDK's own work. """ import time @@ -53,7 +56,7 @@ class Test_FFE_Flag_Selection: def test_tag_selection_loads_only_selected_flags( self, test_library: APMLibrary, mocked_backend: MockedBackendServer ) -> None: - assert test_library.ffe_start({"flagSelection": {"tags": [" team:checkout ", "team:checkout"]}}) + assert test_library.ffe_start(flag_selection={"tags": [" team:checkout ", "team:checkout"]}) selected = _evaluate_boolean(test_library, "basic-flag") requests = mocked_backend.ffe_get_requests() diff --git a/utils/docker_fixtures/_test_clients/_test_client_parametric.py b/utils/docker_fixtures/_test_clients/_test_client_parametric.py index c0dd271e277..15c5aa39aa5 100644 --- a/utils/docker_fixtures/_test_clients/_test_client_parametric.py +++ b/utils/docker_fixtures/_test_clients/_test_client_parametric.py @@ -822,16 +822,21 @@ def ffe_fetch_rules_configuration(self, options: dict[str, Any]) -> dict[str, An response.raise_for_status() return cast("dict[str, Any]", response.json()) - def ffe_start(self, configuration: dict | None = None) -> bool: + def ffe_start(self, configuration: dict | None = None, *, flag_selection: object = None) -> bool: """Initialize the FFE (Feature Flagging & Experimentation) provider. + flag_selection is sent as is in the flag_selection field. The test app must pass it untouched to the SDK's + flag selection provider option: only the SDK canonicalizes and validates it. + Returns: bool: True if the provider was initialized successfully, False otherwise """ - payload = {} + payload: dict[str, object] = {} if configuration is not None: payload["configuration"] = configuration + if flag_selection is not None: + payload["flag_selection"] = flag_selection resp = self._session.post(self._url("/ffe/start"), json=payload) return HTTPStatus(resp.status_code).is_success @@ -1307,9 +1312,9 @@ def ffe_fetch_rules_configuration(self, options: dict[str, Any]) -> dict[str, An """Fetch and serialize portable rules without initializing a tracer or provider.""" return self._client.ffe_fetch_rules_configuration(options) - def ffe_start(self, configuration: dict | None = None) -> bool: + def ffe_start(self, configuration: dict | None = None, *, flag_selection: object = None) -> bool: """Initialize the FFE (Feature Flagging & Experimentation) provider.""" - return self._client.ffe_start(configuration) + return self._client.ffe_start(configuration, flag_selection=flag_selection) def ffe_evaluate( self, From ed97cad3f3c3c3a4fb311aca242cbea9bb353506 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Tue, 3 Nov 2026 16:24:29 +0000 Subject: [PATCH 05/10] test(ffe): revalidate selected configurations with their own ETag ffe-service folds the selection into the ETag, so a 304 for a selection carries the same validator as its 200. The mocked backend's scripted 304 now does the same: under a valid selection it answers 304 only when If-None-Match carries that selection's ETag, and serves the selected flags otherwise. Without a selection the scripted 304 is unchanged. The flag selection test also checks that the SDK's next poll sends the selection's ETag back. --- .../test_ffe/test_flag_selection.py | 12 ++++- .../test_mock_ffe_flag_selection.py | 44 ++++++++++++++++++- utils/mocked_backend/_backend_ffe.py | 40 +++++++++++++---- utils/mocked_backend/ffe.py | 4 ++ 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py index a9385814129..4fac6c3234b 100644 --- a/tests/parametric/test_ffe/test_flag_selection.py +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -17,12 +17,12 @@ from tests.parametric.conftest import APMLibrary from utils import features, scenarios from utils.mocked_backend import MockedBackendServer -from utils.mocked_backend.ffe import EXPECTED_DD_ENV, SELECTED_FLAG_TAGS +from utils.mocked_backend.ffe import EXPECTED_DD_ENV, SELECTED_FLAG_TAGS, Selection, ufc_etag_for @pytest.fixture def library_env(mocked_backend: MockedBackendServer) -> dict[str, str]: - mocked_backend.client.ffe_set_response("valid") + mocked_backend.client.ffe_set_responses(["valid", "not_modified"]) return { "DD_API_KEY": scenarios.parametric.dd_api_key, "DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false", @@ -70,3 +70,11 @@ def test_tag_selection_loads_only_selected_flags( unselected = _evaluate_boolean(test_library, "serial-id-basic-flag") assert unselected.get("value") is False, unselected assert unselected.get("errorCode") == "FLAG_NOT_FOUND", unselected + + deadline = time.monotonic() + 5 + while len(requests := mocked_backend.ffe_get_requests()) < 2 and time.monotonic() < deadline: + time.sleep(0.2) + assert len(requests) >= 2, "the SDK did not poll its configuration again" + assert requests[1]["headers"].get("if-none-match") == ufc_etag_for( + Selection(tags=("team:checkout",), key_patterns=()) + ), "the SDK did not revalidate with the ETag of its selection" diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py index 1b85355df55..94b094b3f94 100644 --- a/tests/test_the_test/test_mock_ffe_flag_selection.py +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -22,6 +22,8 @@ CONFIG_QUERY, FLAG_SELECTION_IGNORED_HEADER, UFC_FIXTURE_RELATIVE_PATH, + Selection, + ufc_etag_for, ) REPO_ROOT = Path(__file__).parents[2] @@ -71,11 +73,11 @@ def json(self) -> dict: return json.loads(self.body) -def _get(server: MockedBackendServer, query: str) -> _Response: +def _get(server: MockedBackendServer, query: str, headers: dict[str, str] | None = None) -> _Response: # Not requests: it re-encodes invalid percent-escapes such as %zz, which the malformed cases must send as is. connection = http.client.HTTPConnection(urlsplit(server.base_url).netloc, timeout=5) try: - connection.request("GET", f"{CONFIG_PATH}?{query}") + connection.request("GET", f"{CONFIG_PATH}?{query}", headers=headers or {}) response = connection.getresponse() return _Response(response.status, response.headers, response.read()) finally: @@ -227,3 +229,41 @@ def test_selection_is_not_applied_to_error_responses() -> None: assert FLAG_SELECTION_IGNORED_HEADER not in response.headers finally: server.close() + + +@scenarios.test_the_test +def test_not_modified_under_a_selection_revalidates_only_its_own_etag() -> None: + server = MockedBackendServer() + selection_query = f"{CONFIG_QUERY}&selected_flag_tags=team%3Acheckout" + try: + server.client.ffe_set_responses(["valid", "not_modified"]) + etag = _get(server, selection_query).headers["ETag"] + assert etag == ufc_etag_for(Selection(tags=("team:checkout",), key_patterns=())) + assert etag != ufc_etag_for(None) + + for validator in (etag, f"W/{etag}", f'"other", {etag}'): + revalidated = _get(server, selection_query, {"If-None-Match": validator}) + assert revalidated.status == 304, validator + assert revalidated.headers["ETag"] == etag, validator + assert FLAG_SELECTION_IGNORED_HEADER not in revalidated.headers, validator + + for headers in ({"If-None-Match": ufc_etag_for(None)}, {}): + stale = _get(server, selection_query, headers) + assert stale.status == 200, headers + assert stale.headers["ETag"] == etag, headers + assert _flag_keys(stale) == ["basic-flag", "numeric_flag"], headers + finally: + server.close() + + +@scenarios.test_the_test +def test_not_modified_without_a_selection_keeps_the_plain_etag() -> None: + server = MockedBackendServer() + try: + server.client.ffe_set_response("not_modified") + for headers in ({"If-None-Match": ufc_etag_for(None)}, {}): + response = _get(server, CONFIG_QUERY, headers) + assert response.status == 304, headers + assert response.headers["ETag"] == ufc_etag_for(None), headers + finally: + server.close() diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index 375d2131e64..b0d21e59027 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -278,19 +278,43 @@ def _has_expected_config_query(raw_query: str) -> bool: return parse_qs("&".join(other_pairs), keep_blank_values=True) == {"dd_env": [EXPECTED_DD_ENV]} +def ufc_etag_for(selection: Selection | None) -> str: + """The ETag of the JSON configuration served for selection; None serves every flag.""" + if selection is None: + return UFC_ETAG + # A 304 must not confirm a body built for another selection. + return f'{UFC_ETAG[:-1]}-{selection.digest()}"' + + +def _matches_if_none_match(if_none_match: str | None, etag: str) -> bool: + """Weak comparison against every validator in the header, like net/http's checkIfNoneMatch.""" + if if_none_match is None: + return False + return any( + candidate.strip() == "*" or candidate.strip().removeprefix("W/") == etag.removeprefix("W/") + for candidate in if_none_match.split(",") + ) + + def _response_for_response( - response: str, selection: Selection | None = None, ignored_reason: str | None = None + response: str, + selection: Selection | None = None, + ignored_reason: str | None = None, + if_none_match: str | None = None, ) -> tuple[int, bytes, dict[str, str]]: if response == "unauthorized": return HTTPStatus.UNAUTHORIZED, b"", {} if response == "malformed": return HTTPStatus.OK, MALFORMED_UFC_BYTES, {"Content-Type": "application/json"} - if response == "not_modified": - return HTTPStatus.NOT_MODIFIED, b"", {"ETag": UFC_ETAG} + # Under a selection, a scripted 304 needs the selection's own validator; any other gets the selected flags. + if response == "not_modified" and ( + selection is None or _matches_if_none_match(if_none_match, ufc_etag_for(selection)) + ): + return HTTPStatus.NOT_MODIFIED, b"", {"ETag": ufc_etag_for(selection)} if response == "server_error": return HTTPStatus.INTERNAL_SERVER_ERROR, b"", {} - if response in {"valid", "delayed_valid", "timeout"}: - headers = {"Content-Type": "application/json", "ETag": UFC_ETAG} + if response in {"valid", "delayed_valid", "timeout", "not_modified"}: + headers = {"Content-Type": "application/json", "ETag": ufc_etag_for(selection)} if ignored_reason is not None: headers[FLAG_SELECTION_IGNORED_HEADER] = ignored_reason if selection is None: @@ -298,8 +322,6 @@ def _response_for_response( flags = json.loads(UFC_FIXTURE_PATH.read_text())["flags"] flag_tags = load_ufc_flag_tags(REPO_ROOT) selected_keys = select_flags(selection, {key: flag_tags.get(key, []) for key in flags}) - # A 304 must not confirm a body built for another selection. - headers["ETag"] = f'{UFC_ETAG[:-1]}-{selection.digest()}"' return HTTPStatus.OK, _valid_ufc_bytes(selected_keys), headers raise ValueError(f"Unsupported JSON response: {response}") @@ -414,7 +436,9 @@ def _ffe_handle_config_exec(self, request: MockedBackendRequestHandler) -> tuple selection, ignored_reason = _flag_selection_for_query( urlparse(request.path).query, self._ffe_state.flag_selection_ignored_reason ) - status_code, body, headers = _response_for_response(response, selection, ignored_reason) + status_code, body, headers = _response_for_response( + response, selection, ignored_reason, request.headers.get("If-None-Match") + ) self._ffe_state.record_response(status_code) with contextlib.suppress(BrokenPipeError, ConnectionResetError): request.send_response(status_code) diff --git a/utils/mocked_backend/ffe.py b/utils/mocked_backend/ffe.py index ecdd2dfbdc0..4e45190ff69 100644 --- a/utils/mocked_backend/ffe.py +++ b/utils/mocked_backend/ffe.py @@ -3,6 +3,7 @@ SELECTED_FLAG_KEY_PATTERNS, SELECTED_FLAG_TAGS, UFC_TAGS_FIXTURE_RELATIVE_PATH, + Selection, ) from ._backend_ffe import ( CONFIG_PATH, @@ -16,6 +17,7 @@ UFC_RESPONSE_TYPE, ffe_library_config_url, load_rules_wire_fixture, + ufc_etag_for, ) __all__ = [ @@ -32,6 +34,8 @@ "UFC_TAGS_FIXTURE_RELATIVE_PATH", "MockedBackendFFEStatus", "ResponseFormat", + "Selection", "ffe_library_config_url", "load_rules_wire_fixture", + "ufc_etag_for", ] From 058ed8b458ee2f6c62295c5db500810796837517 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Tue, 3 Nov 2026 21:56:16 +0000 Subject: [PATCH 06/10] test(ffe): run the flag-selection evaluation suite The flag selection test now runs the ffe-system-test-data flag-selection suite, one test per case file: tags, key patterns, both, canonical form, a tag with interior whitespace, an empty selection, two SDK drops and a selection the server ignores. Each file starts the provider with its raw selection, checks the configuration request against the file's expected canonical selection, then evaluates its cases. The ETag revalidation check stays its own test. The suite is served by the mocked backend: client.ffe_use_suite(name) switches the agentless JSON configuration to that suite's UFC and filters selections with its flag-tags.json. Suites otherwise reach SDKs through Remote Config, which ignores selections. The root UFC no longer has tags. The fixture sync vendors only selection-vectors.json under flag-selection/ and accepts flag-tags.json in a suite. The snapshot is refreshed from DataDog/ffe-system-test-data@be2ab83. --- docs/mocked_backend/README.md | 2 +- .../test_ffe/ffe-system-test-data/SOURCE.md | 2 +- .../flag-selection/cases/canonical-form.json | 148 +++++++++++++++ .../flag-selection/cases/empty-selection.json | 140 ++++++++++++++ .../cases/interior-whitespace-tag.json | 145 ++++++++++++++ .../flag-selection/cases/key-patterns.json | 144 ++++++++++++++ .../cases/sdk-drops-long-url.json | 46 +++++ .../cases/sdk-drops-too-many-values.json | 134 +++++++++++++ .../cases/server-ignores-selection.json | 158 ++++++++++++++++ .../cases/tags-or-key-patterns.json | 150 +++++++++++++++ .../flag-selection/cases/tags.json | 144 ++++++++++++++ .../flag-selection/flag-tags.json | 10 + .../flag-selection/ufc-config.json | 177 ++++++++++++++++++ .../flag-selection/ufc-config-tags.json | 7 - .../test_ffe/test_flag_selection.py | 170 ++++++++++++++--- tests/test_the_test/test_mock_backend_v2.py | 8 +- .../test_mock_ffe_flag_selection.py | 97 ++++++++-- .../test_the_test/test_update_ffe_fixtures.py | 58 ++++-- utils/_context/containers.py | 8 +- utils/mocked_backend/_backend_ffe.py | 108 ++++++++--- utils/mocked_backend/_client.py | 12 ++ utils/mocked_backend/_ffe_flag_selection.py | 7 +- utils/mocked_backend/ffe.py | 6 +- utils/scripts/update_ffe_fixtures.py | 42 +++-- 24 files changed, 1804 insertions(+), 119 deletions(-) create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/empty-selection.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/interior-whitespace-tag.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/key-patterns.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-long-url.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-too-many-values.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/server-ignores-selection.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags-or-key-patterns.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/flag-tags.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/ufc-config.json delete mode 100644 tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json diff --git a/docs/mocked_backend/README.md b/docs/mocked_backend/README.md index 6a3d9a73ccd..23562ae863a 100644 --- a/docs/mocked_backend/README.md +++ b/docs/mocked_backend/README.md @@ -23,7 +23,7 @@ One single way to observe what is sent to the Datadog backend, whatever the setu * **HTTPS on many hostnames**: `mocked-backend.invalid` and the intake hostnames derived from it (`trace.agent.`, `config.`, `instrumentation-telemetry-intake.`, `debugger-intake.`...) are network aliases of its container. Its certificate is generated when it starts, for all of them, signed by the mitmproxy CA (`utils/proxy/.mitmproxy`) the senders trust. * **Remote config**: it serves the remote config backend by default (no config until a test sends one). Tests send configs with `remote_config.send_state`, which sets them on the mocked backend, then waits for the library to apply them. See [Remote config](../edit/remote-config.md). * **Controlled responses**: `MockedBackendResponse` overrides the answer on a path, for the whole session (`MockedBackendContainer.internal_mocked_responses`) or at runtime (`client.set_mocked_responses`), like the proxy's mocked responses. -* **Feature flagging**: endpoints serving the agentless FFE configuration, with scripted responses (`client.ffe_set_response(s)`) and counters (`client.ffe_get_status`). The JSON configuration applies flag selection (`selected_flag_tags`, `selected_flag_key_patterns`) like production, with the shared tags in `ffe-system-test-data/flag-selection/ufc-config-tags.json`; `client.ffe_set_flag_selection_ignored_reason(...)` makes it ignore valid selections, as when production cannot load tags. +* **Feature flagging**: endpoints serving the agentless FFE configuration, with scripted responses (`client.ffe_set_response(s)`) and counters (`client.ffe_get_status`). The JSON configuration applies flag selection (`selected_flag_tags`, `selected_flag_key_patterns`) like production. `client.ffe_use_suite(name)` serves an `ffe-system-test-data` evaluation suite's UFC instead, matching tags from its `flag-tags.json`; `client.ffe_set_flag_selection_ignored_reason(...)` makes it ignore valid selections, as when production cannot load tags. * **Control endpoints**: under `/system-tests/`, never recorded, used by `MockedBackendClient` from the test runner. ## Using it in a scenario diff --git a/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md b/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md index ec63232bc34..ace6cd5f4e2 100644 --- a/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md +++ b/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md @@ -3,7 +3,7 @@ These files are copied from the canonical FFE fixture repository. Canonical source: https://github.com/DataDog/ffe-system-test-data -Source commit: c8cc8da239341748dc8c30c2145c0f0b5432c958 +Source commit: be2ab8372d9cce5c77fbb940266564e7b86ce83a Do not edit these fixtures directly in system-tests. Add or update shared FFE behavior in ffe-system-test-data first, then refresh this snapshot. diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json new file mode 100644 index 00000000000..d28fb87da4f --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json @@ -0,0 +1,148 @@ +[ + { + "description": "Selected by tag team:checkout.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [" team:search ", "team:checkout", "team:search", ""], + "keyPatterns": ["\tops-*", "ops-*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout", "team:search"], + "selectedFlagKeyPatterns": ["ops-*"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Selected by tag team:checkout.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [" team:search ", "team:checkout", "team:search", ""], + "keyPatterns": ["\tops-*", "ops-*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout", "team:search"], + "selectedFlagKeyPatterns": ["ops-*"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Selected by tag team:search.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [" team:search ", "team:checkout", "team:search", ""], + "keyPatterns": ["\tops-*", "ops-*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout", "team:search"], + "selectedFlagKeyPatterns": ["ops-*"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [" team:search ", "team:checkout", "team:search", ""], + "keyPatterns": ["\tops-*", "ops-*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout", "team:search"], + "selectedFlagKeyPatterns": ["ops-*"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Selected by key pattern ops-*.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [" team:search ", "team:checkout", "team:search", ""], + "keyPatterns": ["\tops-*", "ops-*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout", "team:search"], + "selectedFlagKeyPatterns": ["ops-*"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [" team:search ", "team:checkout", "team:search", ""], + "keyPatterns": ["\tops-*", "ops-*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout", "team:search"], + "selectedFlagKeyPatterns": ["ops-*"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/empty-selection.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/empty-selection.json new file mode 100644 index 00000000000..ecbef12eb7b --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/empty-selection.json @@ -0,0 +1,140 @@ +[ + { + "description": "The selection is empty, so the SDK sends none and every flag is served.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [], + "keyPatterns": [] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The selection is empty, so the SDK sends none and every flag is served.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [], + "keyPatterns": [] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The selection is empty, so the SDK sends none and every flag is served.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [], + "keyPatterns": [] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The selection is empty, so the SDK sends none and every flag is served.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [], + "keyPatterns": [] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The selection is empty, so the SDK sends none and every flag is served.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [], + "keyPatterns": [] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The selection is empty, so the SDK sends none and every flag is served.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [], + "keyPatterns": [] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/interior-whitespace-tag.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/interior-whitespace-tag.json new file mode 100644 index 00000000000..4abf3f666ca --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/interior-whitespace-tag.json @@ -0,0 +1,145 @@ +[ + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["release:summer sale"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["release:summer sale"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["release:summer sale"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["release:summer sale"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["release:summer sale"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["release:summer sale"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Selected by tag release:summer sale.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["release:summer sale"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["release:summer sale"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["release:summer sale"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["release:summer sale"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["release:summer sale"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["release:summer sale"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/key-patterns.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/key-patterns.json new file mode 100644 index 00000000000..deeffdfe28e --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/key-patterns.json @@ -0,0 +1,144 @@ +[ + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "keyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [], + "selectedFlagKeyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "keyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [], + "selectedFlagKeyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Selected by key pattern search-*.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "keyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [], + "selectedFlagKeyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Selected by key pattern *-sale.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "keyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [], + "selectedFlagKeyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "keyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [], + "selectedFlagKeyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "keyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [], + "selectedFlagKeyPatterns": ["*-sale", "search-*", "untagged"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-long-url.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-long-url.json new file mode 100644 index 00000000000..e80b90c8fd3 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-long-url.json @@ -0,0 +1,46 @@ +[ + { + "description": "Percent-encoded, the selection takes the request URL over 8192 bytes, so the SDK sends none and every flag is served.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "long-01:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-02:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-03:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-04:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-05:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-06:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-07:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-08:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-09:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-10:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-11:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-12:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-13:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-14:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-15:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-16:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-17:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-18:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-19:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-20:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-21:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-22:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-23:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-24:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-25:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-26:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-27:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-28:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-29:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-30:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Percent-encoded, the selection takes the request URL over 8192 bytes, so the SDK sends none and every flag is served.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "long-01:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-02:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-03:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-04:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-05:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-06:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-07:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-08:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-09:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-10:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-11:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-12:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-13:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-14:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-15:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-16:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-17:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-18:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-19:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-20:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-21:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-22:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-23:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-24:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-25:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-26:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-27:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-28:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-29:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "long-30:ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-too-many-values.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-too-many-values.json new file mode 100644 index 00000000000..90399c3f7a9 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-drops-too-many-values.json @@ -0,0 +1,134 @@ +[ + { + "description": "32 tags exceed the 31-value limit, so the SDK sends no selection and every flag is served.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", "tag-06", "tag-07", "tag-08", "tag-09", "tag-10", "tag-11", "tag-12", "tag-13", "tag-14", "tag-15", "tag-16", "tag-17", "tag-18", "tag-19", "tag-20", "tag-21", "tag-22", "tag-23", "tag-24", "tag-25", "tag-26", "tag-27", "tag-28", "tag-29", "tag-30", "tag-31"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "32 tags exceed the 31-value limit, so the SDK sends no selection and every flag is served.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", "tag-06", "tag-07", "tag-08", "tag-09", "tag-10", "tag-11", "tag-12", "tag-13", "tag-14", "tag-15", "tag-16", "tag-17", "tag-18", "tag-19", "tag-20", "tag-21", "tag-22", "tag-23", "tag-24", "tag-25", "tag-26", "tag-27", "tag-28", "tag-29", "tag-30", "tag-31"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "32 tags exceed the 31-value limit, so the SDK sends no selection and every flag is served.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", "tag-06", "tag-07", "tag-08", "tag-09", "tag-10", "tag-11", "tag-12", "tag-13", "tag-14", "tag-15", "tag-16", "tag-17", "tag-18", "tag-19", "tag-20", "tag-21", "tag-22", "tag-23", "tag-24", "tag-25", "tag-26", "tag-27", "tag-28", "tag-29", "tag-30", "tag-31"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "32 tags exceed the 31-value limit, so the SDK sends no selection and every flag is served.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", "tag-06", "tag-07", "tag-08", "tag-09", "tag-10", "tag-11", "tag-12", "tag-13", "tag-14", "tag-15", "tag-16", "tag-17", "tag-18", "tag-19", "tag-20", "tag-21", "tag-22", "tag-23", "tag-24", "tag-25", "tag-26", "tag-27", "tag-28", "tag-29", "tag-30", "tag-31"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "32 tags exceed the 31-value limit, so the SDK sends no selection and every flag is served.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", "tag-06", "tag-07", "tag-08", "tag-09", "tag-10", "tag-11", "tag-12", "tag-13", "tag-14", "tag-15", "tag-16", "tag-17", "tag-18", "tag-19", "tag-20", "tag-21", "tag-22", "tag-23", "tag-24", "tag-25", "tag-26", "tag-27", "tag-28", "tag-29", "tag-30", "tag-31"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "32 tags exceed the 31-value limit, so the SDK sends no selection and every flag is served.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout", "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", "tag-06", "tag-07", "tag-08", "tag-09", "tag-10", "tag-11", "tag-12", "tag-13", "tag-14", "tag-15", "tag-16", "tag-17", "tag-18", "tag-19", "tag-20", "tag-21", "tag-22", "tag-23", "tag-24", "tag-25", "tag-26", "tag-27", "tag-28", "tag-29", "tag-30", "tag-31"] + } + }, + "expectations": { + "configurationRequest": { + "flagSelection": null + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/server-ignores-selection.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/server-ignores-selection.json new file mode 100644 index 00000000000..e800ffdab2f --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/server-ignores-selection.json @@ -0,0 +1,158 @@ +[ + { + "description": "The server ignores the selection with tag_load_failed, so every flag is served.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "configurationResponse": { + "flagSelectionIgnored": "tag_load_failed" + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The server ignores the selection with tag_load_failed, so every flag is served.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "configurationResponse": { + "flagSelectionIgnored": "tag_load_failed" + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The server ignores the selection with tag_load_failed, so every flag is served.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "configurationResponse": { + "flagSelectionIgnored": "tag_load_failed" + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The server ignores the selection with tag_load_failed, so every flag is served.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "configurationResponse": { + "flagSelectionIgnored": "tag_load_failed" + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The server ignores the selection with tag_load_failed, so every flag is served.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "configurationResponse": { + "flagSelectionIgnored": "tag_load_failed" + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "The server ignores the selection with tag_load_failed, so every flag is served.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "configurationResponse": { + "flagSelectionIgnored": "tag_load_failed" + }, + "result": { + "value": true, + "reason": "STATIC" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags-or-key-patterns.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags-or-key-patterns.json new file mode 100644 index 00000000000..1af0831fa40 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags-or-key-patterns.json @@ -0,0 +1,150 @@ +[ + { + "description": "Selected by key pattern checkout-b*.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:ops"], + "keyPatterns": ["checkout-b*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:ops"], + "selectedFlagKeyPatterns": ["checkout-b*"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:ops"], + "keyPatterns": ["checkout-b*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:ops"], + "selectedFlagKeyPatterns": ["checkout-b*"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:ops"], + "keyPatterns": ["checkout-b*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:ops"], + "selectedFlagKeyPatterns": ["checkout-b*"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:ops"], + "keyPatterns": ["checkout-b*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:ops"], + "selectedFlagKeyPatterns": ["checkout-b*"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Selected by tag team:ops.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:ops"], + "keyPatterns": ["checkout-b*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:ops"], + "selectedFlagKeyPatterns": ["checkout-b*"] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:ops"], + "keyPatterns": ["checkout-b*"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:ops"], + "selectedFlagKeyPatterns": ["checkout-b*"] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags.json new file mode 100644 index 00000000000..f0d7f81b4ea --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/tags.json @@ -0,0 +1,144 @@ +[ + { + "description": "Selected by tag team:checkout.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Selected by tag team:checkout.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": ["team:checkout"] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": ["team:checkout"], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/flag-tags.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/flag-tags.json new file mode 100644 index 00000000000..fb041769c93 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/flag-tags.json @@ -0,0 +1,10 @@ +{ + "flags": [ + {"key": "checkout-banner", "tags": ["team:checkout"]}, + {"key": "checkout-pricing", "tags": ["team:checkout"]}, + {"key": "search-ranking", "tags": ["team:search"]}, + {"key": "summer-sale", "tags": ["release:summer sale"]}, + {"key": "ops-kill-switch", "tags": ["team:ops"]}, + {"key": "untagged-flag", "tags": []} + ] +} diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/ufc-config.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/ufc-config.json new file mode 100644 index 00000000000..22c5b15452d --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/ufc-config.json @@ -0,0 +1,177 @@ +{ + "createdAt": "2026-11-03T00:00:00.000Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "checkout-banner": { + "key": "checkout-banner", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "everyone", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "checkout-pricing": { + "key": "checkout-pricing", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "everyone", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "search-ranking": { + "key": "search-ranking", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "everyone", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "summer-sale": { + "key": "summer-sale", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "everyone", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "ops-kill-switch": { + "key": "ops-kill-switch", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "everyone", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "untagged-flag": { + "key": "untagged-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "everyone", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": false + } + ] + } + } +} diff --git a/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json b/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json deleted file mode 100644 index a88b7453e71..00000000000 --- a/tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "flags": [ - {"key": "basic-flag", "tags": ["team:checkout", "release:summer sale"]}, - {"key": "numeric_flag", "tags": ["team:checkout"]}, - {"key": "experiment-flag", "tags": ["team:search"]} - ] -} diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py index 4fac6c3234b..bb4ec31cbcf 100644 --- a/tests/parametric/test_ffe/test_flag_selection.py +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -1,13 +1,18 @@ """Server SDK flag selection against the mocked FFE agentless backend. Feature under test: a server SDK given a flag selection sends it on the agentless configuration request and serves -only the flags the backend returns. The mocked backend applies the selection to the shared ufc-config.json with the -shared tags in flag-selection/ufc-config-tags.json. +only the flags the backend returns. The cases come from the ffe-system-test-data flag-selection evaluation suite: the +mocked backend serves the suite's ufc-config.json and applies the selection with the suite's flag-tags.json. Each case +file is one provider start. The selection reaches the SDK as written, through ffe_start's flag_selection field, so the canonical selection on the -request is the SDK's own work. +request is the SDK's own work. The Node.js test app builds its provider at boot, so it takes the same raw selection +from SYSTEM_TESTS_FFE_FLAG_SELECTION instead. """ +from dataclasses import dataclass +import json +from pathlib import Path import time from typing import Any from urllib.parse import parse_qs, urlsplit @@ -15,21 +20,89 @@ from utils import pytest from tests.parametric.conftest import APMLibrary -from utils import features, scenarios +from utils import context, features, scenarios from utils.mocked_backend import MockedBackendServer -from utils.mocked_backend.ffe import EXPECTED_DD_ENV, SELECTED_FLAG_TAGS, Selection, ufc_etag_for +from utils.mocked_backend.ffe import ( + EXPECTED_DD_ENV, + SELECTED_FLAG_KEY_PATTERNS, + SELECTED_FLAG_TAGS, + MockedBackendFFERequest, + Selection, + ufc_etag_for, +) +SUITE = "flag-selection" +SUITE_CASES_DIR = Path(__file__).parent / "ffe-system-test-data" / "evaluation-suites" / SUITE / "cases" +REVALIDATION_SELECTION = {"tags": ["team:checkout"]} -@pytest.fixture -def library_env(mocked_backend: MockedBackendServer) -> dict[str, str]: - mocked_backend.client.ffe_set_responses(["valid", "not_modified"]) - return { + +@dataclass(frozen=True) +class _CaseFile: + """One provider start: every case in the file shares its selection, request and server response.""" + + name: str + cases: list[dict[str, Any]] + + @property + def flag_selection(self) -> object: + return self.cases[0].get("providerOptions", {}).get("flagSelection") + + @property + def flag_selection_ignored(self) -> str | None: + return self.cases[0].get("configurationResponse", {}).get("flagSelectionIgnored") + + @property + def expected_query(self) -> dict[str, list[str]]: + request = self.cases[0]["expectations"]["configurationRequest"] + query = {"dd_env": [EXPECTED_DD_ENV]} + for parameter, field in ( + (SELECTED_FLAG_TAGS, "selectedFlagTags"), + (SELECTED_FLAG_KEY_PATTERNS, "selectedFlagKeyPatterns"), + ): + if request.get(field): + query[parameter] = request[field] + return query + + +def _load_case_files() -> list[_CaseFile]: + paths = sorted(SUITE_CASES_DIR.glob("*.json")) + if not paths: + raise FileNotFoundError(f"No flag selection suite cases found in {SUITE_CASES_DIR}") + return [_CaseFile(path.stem, json.loads(path.read_text(encoding="utf-8"))) for path in paths] + + +CASE_FILES = _load_case_files() + + +def _applies_to(case: dict[str, Any], platform: str) -> bool: + skipped = {entry["platform"] for entry in case.get("_skip", [])} + included = {entry["platform"] for entry in case.get("_include", [])} + return platform not in skipped and (not included or platform in included) + + +def _agentless_env(mocked_backend: MockedBackendServer, flag_selection: object) -> dict[str, str]: + env = { "DD_API_KEY": scenarios.parametric.dd_api_key, "DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": "agentless", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": mocked_backend.ffe_library_config_url, "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "1", } + if flag_selection is not None: + env["SYSTEM_TESTS_FFE_FLAG_SELECTION"] = json.dumps(flag_selection) + return env + + +def _config_requests(mocked_backend: MockedBackendServer, count: int) -> list[MockedBackendFFERequest]: + deadline = time.monotonic() + 10 + while len(requests := mocked_backend.ffe_get_requests()) < count and time.monotonic() < deadline: + time.sleep(0.2) + assert len(requests) >= count, f"the SDK made {len(requests)} configuration requests, expected {count}" + return requests + + +def _query(request: MockedBackendFFERequest) -> dict[str, list[str]]: + return parse_qs(urlsplit(request["path"]).query, keep_blank_values=True) def _is_waiting_for_config(result: dict[str, Any]) -> bool: @@ -39,10 +112,14 @@ def _is_waiting_for_config(result: dict[str, Any]) -> bool: ) -def _evaluate_boolean(test_library: APMLibrary, flag: str) -> dict[str, Any]: +def _evaluate(test_library: APMLibrary, case: dict[str, Any]) -> dict[str, Any]: for _ in range(10): result = test_library.ffe_evaluate( - flag=flag, variation_type="BOOLEAN", default_value=False, targeting_key="alice", attributes={} + flag=case["flag"], + variation_type=case["variationType"], + default_value=case["defaultValue"], + targeting_key=case["targetingKey"], + attributes=case["attributes"], ) if not _is_waiting_for_config(result): break @@ -53,28 +130,59 @@ def _evaluate_boolean(test_library: APMLibrary, flag: str) -> dict[str, Any]: @scenarios.parametric @features.feature_flags_configuration_filtering class Test_FFE_Flag_Selection: - def test_tag_selection_loads_only_selected_flags( - self, test_library: APMLibrary, mocked_backend: MockedBackendServer + @pytest.fixture + def library_env(self, mocked_backend: MockedBackendServer, case_file: _CaseFile) -> dict[str, str]: + # Some SDKs fetch their configuration as soon as the library starts. + mocked_backend.client.ffe_use_suite(SUITE) + if case_file.flag_selection_ignored is not None: + mocked_backend.client.ffe_set_flag_selection_ignored_reason(case_file.flag_selection_ignored) + return _agentless_env(mocked_backend, case_file.flag_selection) + + @pytest.mark.parametrize("case_file", CASE_FILES, ids=[case_file.name for case_file in CASE_FILES]) + def test_flag_selection_suite( + self, case_file: _CaseFile, test_library: APMLibrary, mocked_backend: MockedBackendServer ) -> None: - assert test_library.ffe_start(flag_selection={"tags": [" team:checkout ", "team:checkout"]}) + cases = [case for case in case_file.cases if _applies_to(case, context.library.name)] + if not cases: + pytest.skip(f"No cases in {case_file.name} apply to {context.library.name}") + + assert test_library.ffe_start(flag_selection=case_file.flag_selection), "Failed to start FFE provider" + request = _config_requests(mocked_backend, 1)[0] + assert _query(request) == case_file.expected_query, ( + f"{case_file.name}: the configuration request does not carry the expected selection" + ) + + for index, case in enumerate(cases): + expected = case["result"] + result = _evaluate(test_library, case) + location = f"{case_file.name} case {index} ({case['flag']})" + assert not _is_waiting_for_config(result), f"{location}: the provider loaded no configuration: {result}" + assert result.get("value") == expected["value"], f"{location}: {result}" + assert result.get("reason") == expected["reason"], f"{location}: {result}" + if "errorCode" in expected: + assert result.get("errorCode") == expected["errorCode"], f"{location}: {result}" + else: + assert result.get("errorCode") in {None, ""}, f"{location}: {result}" - selected = _evaluate_boolean(test_library, "basic-flag") - requests = mocked_backend.ffe_get_requests() - assert requests, "the SDK did not fetch its configuration" - assert parse_qs(urlsplit(requests[0]["path"]).query, keep_blank_values=True) == { + +@scenarios.parametric +@features.feature_flags_configuration_filtering +class Test_FFE_Flag_Selection_Revalidation: + @pytest.fixture + def library_env(self, mocked_backend: MockedBackendServer) -> dict[str, str]: + mocked_backend.client.ffe_use_suite(SUITE) + mocked_backend.client.ffe_set_responses(["valid", "not_modified"]) + return _agentless_env(mocked_backend, REVALIDATION_SELECTION) + + def test_selected_configuration_revalidates_with_its_etag( + self, test_library: APMLibrary, mocked_backend: MockedBackendServer + ) -> None: + assert test_library.ffe_start(flag_selection=REVALIDATION_SELECTION), "Failed to start FFE provider" + assert _query(_config_requests(mocked_backend, 1)[0]) == { "dd_env": [EXPECTED_DD_ENV], SELECTED_FLAG_TAGS: ["team:checkout"], - }, "the configuration request does not carry the canonical selection" - assert selected.get("value") is True, selected - - unselected = _evaluate_boolean(test_library, "serial-id-basic-flag") - assert unselected.get("value") is False, unselected - assert unselected.get("errorCode") == "FLAG_NOT_FOUND", unselected - - deadline = time.monotonic() + 5 - while len(requests := mocked_backend.ffe_get_requests()) < 2 and time.monotonic() < deadline: - time.sleep(0.2) - assert len(requests) >= 2, "the SDK did not poll its configuration again" - assert requests[1]["headers"].get("if-none-match") == ufc_etag_for( - Selection(tags=("team:checkout",), key_patterns=()) + }, "the configuration request does not carry the selection" + + assert _config_requests(mocked_backend, 2)[1]["headers"].get("if-none-match") == ufc_etag_for( + Selection(tags=("team:checkout",), key_patterns=()), SUITE ), "the SDK did not revalidate with the ETag of its selection" diff --git a/tests/test_the_test/test_mock_backend_v2.py b/tests/test_the_test/test_mock_backend_v2.py index 128688d4e4b..0c9aeeb38e2 100644 --- a/tests/test_the_test/test_mock_backend_v2.py +++ b/tests/test_the_test/test_mock_backend_v2.py @@ -24,7 +24,7 @@ from utils._context._scenarios.agentless_endtoend import JAVA_TRUSTSTORE_SOURCE from utils.mocked_backend import MockedBackendClient, MockedBackendServer from utils.mocked_backend._tls import generate_certificate -from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH, UFC_TAGS_FIXTURE_RELATIVE_PATH +from utils.mocked_backend.ffe import EVALUATION_SUITES_RELATIVE_PATH, UFC_FIXTURE_RELATIVE_PATH from utils.proxy.mocked_response import MockedBackendResponse @@ -329,7 +329,7 @@ def test_runs_without_the_rest_of_utils(tmp_path: Path): (tmp_path / "utils").mkdir() # no __init__.py: a namespace package, as in the proxy image for package in ("mocked_backend", "proxy"): (tmp_path / "utils" / package).symlink_to(repo_root / "utils" / package) - for fixture in (UFC_FIXTURE_RELATIVE_PATH, UFC_TAGS_FIXTURE_RELATIVE_PATH): + for fixture in (UFC_FIXTURE_RELATIVE_PATH, EVALUATION_SUITES_RELATIVE_PATH): (tmp_path / fixture).parent.mkdir(parents=True, exist_ok=True) (tmp_path / fixture).symlink_to(repo_root / fixture) @@ -347,14 +347,18 @@ def test_runs_without_the_rest_of_utils(tmp_path: Path): "from utils.mocked_backend.ffe import CONFIG_PATH\n" "from utils.mocked_backend.ffe import ffe_library_config_url\n" "import utils.mocked_backend.__main__\n" + "import json\n" "from urllib.request import urlopen\n" "server = MockedBackendServer(port=0)\n" "try:\n" " with urlopen(ffe_library_config_url(server.base_url), timeout=5) as response:\n" " assert response.status == 200, response.status\n" + " server.client.ffe_use_suite('flag-selection')\n" " selection_url = ffe_library_config_url(server.base_url) + '&selected_flag_tags=team%3Acheckout'\n" " with urlopen(selection_url, timeout=5) as response:\n" " assert response.status == 200, response.status\n" + " assert sorted(json.load(response)['data']['attributes']['flags']) == " + "['checkout-banner', 'checkout-pricing']\n" "finally:\n" " server.close()\n" ) diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py index 94b094b3f94..0ede6fe3711 100644 --- a/tests/test_the_test/test_mock_ffe_flag_selection.py +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -20,6 +20,7 @@ from utils.mocked_backend.ffe import ( CONFIG_PATH, CONFIG_QUERY, + EVALUATION_SUITES_RELATIVE_PATH, FLAG_SELECTION_IGNORED_HEADER, UFC_FIXTURE_RELATIVE_PATH, Selection, @@ -31,6 +32,11 @@ (REPO_ROOT / "tests/parametric/test_ffe/ffe-system-test-data/flag-selection/selection-vectors.json").read_text() ) UFC_FLAGS = sorted(json.loads((REPO_ROOT / UFC_FIXTURE_RELATIVE_PATH).read_text())["flags"]) +SUITE = "flag-selection" +SUITE_FLAGS = sorted( + json.loads((REPO_ROOT / EVALUATION_SUITES_RELATIVE_PATH / SUITE / "ufc-config.json").read_text())["flags"] +) +CHECKOUT_FLAGS = ["checkout-banner", "checkout-pricing"] def _encode(value: str) -> str: @@ -98,16 +104,13 @@ def _flag_keys(response: _Response) -> list[str]: @pytest.mark.parametrize( ("selection_query", "expected_keys"), [ - ("selected_flag_tags=team%3Acheckout", ["basic-flag", "numeric_flag"]), - ("selected_flag_tags=release%3Asummer%20sale", ["basic-flag"]), - ("selected_flag_tags=release:summer+sale", ["basic-flag"]), - ( - "selected_flag_key_patterns=serial-id-%2A", - ["serial-id-basic-flag", "serial-id-missing-flag", "serial-id-targeting-flag", "serial-id-zero-flag"], - ), + ("selected_flag_tags=team%3Acheckout", CHECKOUT_FLAGS), + ("selected_flag_tags=release%3Asummer%20sale", ["summer-sale"]), + ("selected_flag_tags=release:summer+sale", ["summer-sale"]), + ("selected_flag_key_patterns=search-%2A", ["search-ranking"]), ( - "selected_flag_tags=team%3Asearch&selected_flag_key_patterns=numeric_flag", - ["experiment-flag", "numeric_flag"], + "selected_flag_tags=team%3Aops&selected_flag_key_patterns=checkout-b%2A", + ["checkout-banner", "ops-kill-switch"], ), ("selected_flag_tags=no-flag-has-this-tag", []), ("selected_flag_tags=%85", []), @@ -116,6 +119,7 @@ def _flag_keys(response: _Response) -> list[str]: def test_valid_selection_serves_only_selected_flags(selection_query: str, expected_keys: list[str]) -> None: server = MockedBackendServer() try: + server.client.ffe_use_suite(SUITE) unfiltered = _get_config(server) response = _get_config(server, selection_query) @@ -131,6 +135,64 @@ def test_valid_selection_serves_only_selected_flags(selection_query: str, expect server.close() +@scenarios.test_the_test +def test_root_configuration_has_no_tags() -> None: + server = MockedBackendServer() + try: + assert _flag_keys(_get_config(server, "selected_flag_tags=team%3Acheckout")) == [] + assert _flag_keys(_get_config(server, "selected_flag_key_patterns=serial-id-%2A")) == [ + "serial-id-basic-flag", + "serial-id-missing-flag", + "serial-id-targeting-flag", + "serial-id-zero-flag", + ] + finally: + server.close() + + +@scenarios.test_the_test +def test_suite_control_serves_the_suite_configuration() -> None: + server = MockedBackendServer() + try: + server.client.ffe_use_suite(SUITE) + response = _get_config(server) + assert _flag_keys(response) == SUITE_FLAGS + assert response.headers["ETag"] == ufc_etag_for(None, SUITE) + + server.client.ffe_use_suite("dependent-flags") + assert _flag_keys(_get_config(server, "selected_flag_tags=team%3Acheckout")) == [] + + server.client.ffe_use_suite(None) + response = _get_config(server) + assert _flag_keys(response) == UFC_FLAGS + assert response.headers["ETag"] == ufc_etag_for(None) + + server.client.ffe_use_suite(SUITE) + server.client.ffe_reset() + assert _flag_keys(_get_config(server)) == UFC_FLAGS + finally: + server.close() + + +@scenarios.test_the_test +def test_suite_control_rejects_unknown_suites() -> None: + server = MockedBackendServer() + try: + for body in ( + {"suite": "missing-suite"}, + {"suite": "../evaluation-suites/flag-selection"}, + {"suite": "Flag-Selection"}, + {"suite": ""}, + {"suite": 1}, + {}, + {"other": None}, + ): + response = requests.post(f"{server.base_url}/system-tests/ffe/suite", json=body, timeout=5) + assert response.status_code == 400, body + finally: + server.close() + + @scenarios.test_the_test @pytest.mark.parametrize( ("selection_query", "reason"), @@ -183,10 +245,11 @@ def test_selection_does_not_relax_the_environment_query() -> None: def test_forced_ignored_reason_applies_only_to_valid_selections() -> None: server = MockedBackendServer() try: + server.client.ffe_use_suite(SUITE) server.client.ffe_set_flag_selection_ignored_reason("tag_load_failed") response = _get_config(server, "selected_flag_tags=team%3Acheckout") - assert _flag_keys(response) == UFC_FLAGS + assert _flag_keys(response) == SUITE_FLAGS assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == "tag_load_failed" response = _get_config(server) @@ -197,7 +260,7 @@ def test_forced_ignored_reason_applies_only_to_valid_selections() -> None: server.client.ffe_set_flag_selection_ignored_reason(None) response = _get_config(server, "selected_flag_tags=team%3Acheckout") - assert _flag_keys(response) == ["basic-flag", "numeric_flag"] + assert _flag_keys(response) == CHECKOUT_FLAGS server.client.ffe_set_flag_selection_ignored_reason("future_reason") server.client.ffe_reset() @@ -236,10 +299,14 @@ def test_not_modified_under_a_selection_revalidates_only_its_own_etag() -> None: server = MockedBackendServer() selection_query = f"{CONFIG_QUERY}&selected_flag_tags=team%3Acheckout" try: + server.client.ffe_use_suite(SUITE) server.client.ffe_set_responses(["valid", "not_modified"]) etag = _get(server, selection_query).headers["ETag"] - assert etag == ufc_etag_for(Selection(tags=("team:checkout",), key_patterns=())) - assert etag != ufc_etag_for(None) + assert etag == ufc_etag_for(Selection(tags=("team:checkout",), key_patterns=()), SUITE) + assert etag not in { + ufc_etag_for(None, SUITE), + ufc_etag_for(Selection(tags=("team:checkout",), key_patterns=())), + } for validator in (etag, f"W/{etag}", f'"other", {etag}'): revalidated = _get(server, selection_query, {"If-None-Match": validator}) @@ -247,11 +314,11 @@ def test_not_modified_under_a_selection_revalidates_only_its_own_etag() -> None: assert revalidated.headers["ETag"] == etag, validator assert FLAG_SELECTION_IGNORED_HEADER not in revalidated.headers, validator - for headers in ({"If-None-Match": ufc_etag_for(None)}, {}): + for headers in ({"If-None-Match": ufc_etag_for(None, SUITE)}, {}): stale = _get(server, selection_query, headers) assert stale.status == 200, headers assert stale.headers["ETag"] == etag, headers - assert _flag_keys(stale) == ["basic-flag", "numeric_flag"], headers + assert _flag_keys(stale) == CHECKOUT_FLAGS, headers finally: server.close() diff --git a/tests/test_the_test/test_update_ffe_fixtures.py b/tests/test_the_test/test_update_ffe_fixtures.py index 6b8a223cb78..7f6ea2261e9 100644 --- a/tests/test_the_test/test_update_ffe_fixtures.py +++ b/tests/test_the_test/test_update_ffe_fixtures.py @@ -60,22 +60,18 @@ def test_copy_fixture_snapshot_rejects_symbolic_links(tmp_path: Path) -> None: @scenarios.test_the_test -def test_copy_flag_selection_snapshot_imports_only_vectors_and_tags(tmp_path: Path) -> None: +def test_copy_flag_selection_snapshot_imports_only_vectors(tmp_path: Path) -> None: source = tmp_path / "source" snapshot = tmp_path / "snapshot" (source / "flag-selection").mkdir(parents=True) snapshot.mkdir() (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") - (source / "flag-selection" / "ufc-config-tags.json").write_text('{"flags": []}', encoding="utf-8") (source / "flag-selection" / "README.md").write_text("upstream documentation", encoding="utf-8") update_ffe_fixtures._copy_flag_selection_snapshot(source, snapshot) update_ffe_fixtures._validate_flag_selection_snapshot(snapshot) - assert sorted(path.name for path in (snapshot / "flag-selection").iterdir()) == [ - "selection-vectors.json", - "ufc-config-tags.json", - ] + assert [path.name for path in (snapshot / "flag-selection").iterdir()] == ["selection-vectors.json"] @scenarios.test_the_test @@ -90,12 +86,12 @@ def test_copy_flag_selection_snapshot_skips_absent_directory(tmp_path: Path) -> @scenarios.test_the_test -def test_copy_flag_selection_snapshot_requires_tags(tmp_path: Path) -> None: +def test_copy_flag_selection_snapshot_requires_vectors(tmp_path: Path) -> None: source = tmp_path / "source" (source / "flag-selection").mkdir(parents=True) - (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") + (source / "flag-selection" / "README.md").write_text("upstream documentation", encoding="utf-8") - with pytest.raises(ValueError, match="must contain ufc-config-tags.json"): + with pytest.raises(ValueError, match="must contain selection-vectors.json"): update_ffe_fixtures._copy_flag_selection_snapshot(source, tmp_path / "snapshot") @@ -114,13 +110,51 @@ def test_copy_flag_selection_snapshot_rejects_symbolic_link_directory(tmp_path: @scenarios.test_the_test def test_validate_flag_selection_snapshot_rejects_unexpected_fields(tmp_path: Path) -> None: (tmp_path / "flag-selection").mkdir() - (tmp_path / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") - (tmp_path / "flag-selection" / "ufc-config-tags.json").write_text('{"flags": [], "extra": 1}', encoding="utf-8") + (tmp_path / "flag-selection" / "selection-vectors.json").write_text( + '{"flags": [], "cases": [], "extra": 1}', encoding="utf-8" + ) - with pytest.raises(ValueError, match="ufc-config-tags.json must contain only flags"): + with pytest.raises(ValueError, match="selection-vectors.json must contain only cases, flags"): update_ffe_fixtures._validate_flag_selection_snapshot(tmp_path) +def _write_suite(snapshot: Path) -> Path: + (snapshot / "evaluation-cases").mkdir(parents=True) + (snapshot / "ufc-config.json").write_text('{"flags": {"one": {}}}', encoding="utf-8") + (snapshot / "evaluation-cases" / "case.json").write_text('[{"flag": "one"}]', encoding="utf-8") + suite = snapshot / "evaluation-suites" / "flag-selection" + (suite / "cases").mkdir(parents=True) + (suite / "ufc-config.json").write_text('{"flags": {"two": {}}}', encoding="utf-8") + (suite / "cases" / "tags.json").write_text('[{"flag": "two"}]', encoding="utf-8") + return suite + + +@scenarios.test_the_test +def test_validate_fixture_snapshot_accepts_suite_flag_tags(tmp_path: Path) -> None: + suite = _write_suite(tmp_path) + (suite / "flag-tags.json").write_text('{"flags": [{"key": "two", "tags": ["team:checkout"]}]}', encoding="utf-8") + + assert update_ffe_fixtures._validate_fixture_snapshot(tmp_path) == 2 + + +@scenarios.test_the_test +@pytest.mark.parametrize( + ("name", "content", "message"), + [ + ("flag-tags.json", '{"flags": {}}', "must contain only a flags array"), + ("flag-tags.json", '{"flags": [], "extra": 1}', "must contain only a flags array"), + ("notes.json", "{}", "must contain only ufc-config.json, cases and an optional flag-tags.json"), + ], +) +def test_validate_fixture_snapshot_rejects_invalid_suite_entries( + tmp_path: Path, name: str, content: str, message: str +) -> None: + (_write_suite(tmp_path) / name).write_text(content, encoding="utf-8") + + with pytest.raises(ValueError, match=message): + update_ffe_fixtures._validate_fixture_snapshot(tmp_path) + + @scenarios.test_the_test def test_validate_fixture_snapshot_counts_evaluations(tmp_path: Path) -> None: cases_directory = tmp_path / "evaluation-cases" diff --git a/utils/_context/containers.py b/utils/_context/containers.py index 8f1a41bbe2e..ad04dc61236 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -27,7 +27,7 @@ from utils.docker_fixtures._core import extra_hosts_for_environment from utils.proxy.tuf import get_tuf_root_json from utils.mocked_backend import MOCKED_BACKEND_DOMAIN, MockedBackendClient, MockedBackendServer -from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH, UFC_TAGS_FIXTURE_RELATIVE_PATH +from utils.mocked_backend.ffe import EVALUATION_SUITES_RELATIVE_PATH, UFC_FIXTURE_RELATIVE_PATH from utils.proxy.ports import ProxyPorts from utils.proxy.mocked_response import ( RemoveMetaStructsSupport, @@ -807,9 +807,9 @@ def __init__(self, *, tls: bool = False) -> None: "./utils/proxy": {"bind": "/app/utils/proxy", "mode": "ro"}, # served as the FFE agentless configuration f"./{UFC_FIXTURE_RELATIVE_PATH}": {"bind": f"/app/{UFC_FIXTURE_RELATIVE_PATH}", "mode": "ro"}, - # the tags its flag selection matches against - f"./{UFC_TAGS_FIXTURE_RELATIVE_PATH}": { - "bind": f"/app/{UFC_TAGS_FIXTURE_RELATIVE_PATH}", + # served instead with client.ffe_use_suite, with the tags its flag selection matches against + f"./{EVALUATION_SUITES_RELATIVE_PATH}": { + "bind": f"/app/{EVALUATION_SUITES_RELATIVE_PATH}", "mode": "ro", }, }, diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index b0d21e59027..cfc9b3319a6 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -6,11 +6,12 @@ ``mocked_backend.client.ffe_get_status()`` reports request counts, auth, ETag, response codes, and paths observed across both formats. -The JSON configuration applies the ``selected_flag_tags`` and ``selected_flag_key_patterns`` query -parameters like production, using the shared tags in ``UFC_TAGS_FIXTURE_RELATIVE_PATH``. -``mocked_backend.client.ffe_set_flag_selection_ignored_reason(...)`` makes it ignore every valid -selection with that reason, as when production cannot load flag tags. The protobuf helpers ignore -selection. +The JSON configuration serves the root UFC, or with ``mocked_backend.client.ffe_use_suite(name)`` +the UFC of that ffe-system-test-data evaluation suite. It applies the ``selected_flag_tags`` and +``selected_flag_key_patterns`` query parameters like production, matching tags from the suite's +``flag-tags.json``; the root UFC has no tags. ``mocked_backend.client.ffe_set_flag_selection_ignored_reason(...)`` +makes it ignore every valid selection with that reason, as when production cannot load flag tags. +The protobuf helpers ignore both. """ from __future__ import annotations @@ -21,6 +22,7 @@ import json import os from pathlib import Path +import re import socket import threading import time @@ -33,7 +35,7 @@ SelectionIgnoredError, has_selection_parameters, is_selection_parameter, - load_ufc_flag_tags, + load_flag_tags, parse_selection, select_flags, validate_forced_reason, @@ -89,9 +91,11 @@ EXPECTED_DD_ENV = "test" CONFIG_QUERY = urlencode({"dd_env": EXPECTED_DD_ENV}) REPO_ROOT = Path(__file__).parents[2] -# relative to the repository root: the mocked backend container mounts it at the same place +# relative to the repository root: the mocked backend container mounts them at the same place UFC_FIXTURE_RELATIVE_PATH = "tests/parametric/test_ffe/ffe-system-test-data/ufc-config.json" +EVALUATION_SUITES_RELATIVE_PATH = "tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites" UFC_FIXTURE_PATH = REPO_ROOT / UFC_FIXTURE_RELATIVE_PATH +SUITE_NAME_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") MALFORMED_UFC_BYTES = b'{"flags": [' UFC_RESPONSE_TYPE = "universal-flag-configuration" RESPONSE_SECRET = "private-response-body" # noqa: S105 - dummy body used to detect error-message leakage @@ -138,6 +142,7 @@ def __init__(self) -> None: self.status_codes: list[int] = [] self.requests: list[MockedBackendFFERequest] = [] self._flag_selection_ignored_reason: str | None = None + self._suite: str | None = None def reset(self) -> None: with self._lock: @@ -152,6 +157,7 @@ def reset(self) -> None: self.status_codes = [] self.requests = [] self._flag_selection_ignored_reason = None + self._suite = None def set_responses(self, responses: object, response_format: ResponseFormat) -> None: validated_responses = validate_responses(responses, response_format) @@ -188,6 +194,15 @@ def set_flag_selection_ignored_reason(self, reason: str | None) -> None: with self._lock: self._flag_selection_ignored_reason = reason + @property + def suite(self) -> str | None: + with self._lock: + return self._suite + + def set_suite(self, suite: str | None) -> None: + with self._lock: + self._suite = suite + def record_response(self, status_code: int) -> None: with self._lock: self.last_status_code = status_code @@ -220,8 +235,34 @@ def _get_dd_api_key(headers: Mapping[str, str]) -> str | None: return normalized.get("dd-api-key") -def _valid_ufc_bytes(selected_keys: list[str] | None = None) -> bytes: - attributes = json.loads(UFC_FIXTURE_PATH.read_text()) +def _suite_directory(suite: str) -> Path: + return REPO_ROOT / EVALUATION_SUITES_RELATIVE_PATH / suite + + +def validate_suite(suite: object) -> str | None: + if suite is None: + return None + if ( + not isinstance(suite, str) + or SUITE_NAME_PATTERN.fullmatch(suite) is None + or not (_suite_directory(suite) / "ufc-config.json").is_file() + ): + raise ValueError("suite must be null or the name of an evaluation suite in the FFE fixture snapshot") + return suite + + +def _ufc_path(suite: str | None) -> Path: + return UFC_FIXTURE_PATH if suite is None else _suite_directory(suite) / "ufc-config.json" + + +def _flag_tags(suite: str | None) -> dict[str, list[str]]: + if suite is None or not (path := _suite_directory(suite) / "flag-tags.json").is_file(): + return {} + return load_flag_tags(path) + + +def _valid_ufc_bytes(selected_keys: list[str] | None = None, suite: str | None = None) -> bytes: + attributes = json.loads(_ufc_path(suite).read_text()) if selected_keys is not None: attributes["flags"] = {key: attributes["flags"][key] for key in selected_keys} return json.dumps( @@ -278,12 +319,15 @@ def _has_expected_config_query(raw_query: str) -> bool: return parse_qs("&".join(other_pairs), keep_blank_values=True) == {"dd_env": [EXPECTED_DD_ENV]} -def ufc_etag_for(selection: Selection | None) -> str: - """The ETag of the JSON configuration served for selection; None serves every flag.""" - if selection is None: - return UFC_ETAG - # A 304 must not confirm a body built for another selection. - return f'{UFC_ETAG[:-1]}-{selection.digest()}"' +def ufc_etag_for(selection: Selection | None, suite: str | None = None) -> str: + """The ETag of the JSON configuration served from suite (None: the root UFC) for selection (None: every flag).""" + # A 304 must not confirm a body built from another configuration or for another selection. + etag = UFC_ETAG[:-1] + if suite is not None: + etag += f"-{suite}" + if selection is not None: + etag += f"-{selection.digest()}" + return f'{etag}"' def _matches_if_none_match(if_none_match: str | None, etag: str) -> bool: @@ -301,28 +345,28 @@ def _response_for_response( selection: Selection | None = None, ignored_reason: str | None = None, if_none_match: str | None = None, + suite: str | None = None, ) -> tuple[int, bytes, dict[str, str]]: if response == "unauthorized": return HTTPStatus.UNAUTHORIZED, b"", {} if response == "malformed": return HTTPStatus.OK, MALFORMED_UFC_BYTES, {"Content-Type": "application/json"} # Under a selection, a scripted 304 needs the selection's own validator; any other gets the selected flags. - if response == "not_modified" and ( - selection is None or _matches_if_none_match(if_none_match, ufc_etag_for(selection)) - ): - return HTTPStatus.NOT_MODIFIED, b"", {"ETag": ufc_etag_for(selection)} + etag = ufc_etag_for(selection, suite) + if response == "not_modified" and (selection is None or _matches_if_none_match(if_none_match, etag)): + return HTTPStatus.NOT_MODIFIED, b"", {"ETag": etag} if response == "server_error": return HTTPStatus.INTERNAL_SERVER_ERROR, b"", {} if response in {"valid", "delayed_valid", "timeout", "not_modified"}: - headers = {"Content-Type": "application/json", "ETag": ufc_etag_for(selection)} + headers = {"Content-Type": "application/json", "ETag": etag} if ignored_reason is not None: headers[FLAG_SELECTION_IGNORED_HEADER] = ignored_reason if selection is None: - return HTTPStatus.OK, _valid_ufc_bytes(), headers - flags = json.loads(UFC_FIXTURE_PATH.read_text())["flags"] - flag_tags = load_ufc_flag_tags(REPO_ROOT) + return HTTPStatus.OK, _valid_ufc_bytes(suite=suite), headers + flags = json.loads(_ufc_path(suite).read_text())["flags"] + flag_tags = _flag_tags(suite) selected_keys = select_flags(selection, {key: flag_tags.get(key, []) for key in flags}) - return HTTPStatus.OK, _valid_ufc_bytes(selected_keys), headers + return HTTPStatus.OK, _valid_ufc_bytes(selected_keys, suite), headers raise ValueError(f"Unsupported JSON response: {response}") @@ -367,6 +411,7 @@ class MockedBackendServerFfeMixin(_Base): # - POST /system-tests/ffe/responses # - POST /system-tests/ffe/reset # - POST /system-tests/ffe/flag-selection + # - POST /system-tests/ffe/suite def _ffe_configure(self) -> None: self._ffe_state = _MockedBackendFFEState() @@ -384,6 +429,7 @@ def _ffe_configure(self) -> None: self.add_handler("POST", f"{self._ffe_control_path}/responses", self._ffe_handle_control_responses) self.add_handler("POST", f"{self._ffe_control_path}/reset", self._ffe_handle_control_reset) self.add_handler("POST", f"{self._ffe_control_path}/flag-selection", self._ffe_handle_control_flag_selection) + self.add_handler("POST", f"{self._ffe_control_path}/suite", self._ffe_handle_control_suite) @property def ffe_library_base_url(self) -> str: @@ -437,7 +483,7 @@ def _ffe_handle_config_exec(self, request: MockedBackendRequestHandler) -> tuple urlparse(request.path).query, self._ffe_state.flag_selection_ignored_reason ) status_code, body, headers = _response_for_response( - response, selection, ignored_reason, request.headers.get("If-None-Match") + response, selection, ignored_reason, request.headers.get("If-None-Match"), self._ffe_state.suite ) self._ffe_state.record_response(status_code) with contextlib.suppress(BrokenPipeError, ConnectionResetError): @@ -531,6 +577,18 @@ def _ffe_handle_control_flag_selection( self._ffe_state.set_flag_selection_ignored_reason(reason) return request.write_json(HTTPStatus.OK, {"ignored_reason": reason}) + def _ffe_handle_control_suite( + self, request: MockedBackendRequestHandler, payload: bytes | dict | None + ) -> tuple[Mapping, bytes]: + if not isinstance(payload, dict) or set(payload) != {"suite"}: + return request.write_json(HTTPStatus.BAD_REQUEST, {"error": "control body must contain suite"}) + try: + suite = validate_suite(payload["suite"]) + except ValueError as error: + return request.write_json(HTTPStatus.BAD_REQUEST, {"error": str(error)}) + self._ffe_state.set_suite(suite) + return request.write_json(HTTPStatus.OK, {"suite": suite}) + def _ffe_handle_control_reset( self, request: MockedBackendRequestHandler, _: bytes | dict | None ) -> tuple[Mapping, bytes]: diff --git a/utils/mocked_backend/_client.py b/utils/mocked_backend/_client.py index 82386cf7d22..1f18d8226a6 100644 --- a/utils/mocked_backend/_client.py +++ b/utils/mocked_backend/_client.py @@ -53,6 +53,18 @@ def ffe_set_flag_selection_ignored_reason(self, reason: str | None) -> None: ) response.raise_for_status() + def ffe_use_suite(self, suite: str | None) -> None: + """Serve the JSON FFE configuration from this ffe-system-test-data evaluation suite, filtering selections with + its flag-tags.json; None serves the root UFC again + """ + response = requests.post( + f"{self.base_url}{_FFE_CONTROL_PATH}/suite", + json={"suite": suite}, + timeout=5, + verify=self.verify, + ) + response.raise_for_status() + def set_mocked_responses(self, responses: Sequence[MockedBackendResponse]) -> None: """Replace the mocked responses set by tests, like MockedBackendResponse.send() does with the proxy""" response = requests.put( diff --git a/utils/mocked_backend/_ffe_flag_selection.py b/utils/mocked_backend/_ffe_flag_selection.py index 751104831ff..5c62b1b3928 100644 --- a/utils/mocked_backend/_ffe_flag_selection.py +++ b/utils/mocked_backend/_ffe_flag_selection.py @@ -26,7 +26,6 @@ MAX_VALUES = 31 MAX_VALUE_BYTES = 200 FORCED_REASON_PATTERN = re.compile(r"[A-Za-z0-9_.-]{1,64}") -UFC_TAGS_FIXTURE_RELATIVE_PATH = "tests/parametric/test_ffe/ffe-system-test-data/flag-selection/ufc-config-tags.json" # The Unicode White_Space property. str.strip() would also remove U+001C..U+001F, which production keeps. _WHITE_SPACE = "".join( @@ -168,9 +167,9 @@ def select_flags(selection: Selection, flag_tags: Mapping[str, Sequence[str]]) - ) -def load_ufc_flag_tags(repository_root: Path) -> dict[str, list[str]]: - """Tags of the shared UFC flags. A flag that is not listed has no tags.""" - fixture = json.loads((repository_root / UFC_TAGS_FIXTURE_RELATIVE_PATH).read_text(encoding="utf-8")) +def load_flag_tags(path: Path) -> dict[str, list[str]]: + """Tags per flag key from an evaluation suite's flag-tags.json. A flag that is not listed has no tags.""" + fixture = json.loads(path.read_text(encoding="utf-8")) return {flag["key"]: list(flag["tags"]) for flag in fixture["flags"]} diff --git a/utils/mocked_backend/ffe.py b/utils/mocked_backend/ffe.py index 4e45190ff69..dd28d7c508f 100644 --- a/utils/mocked_backend/ffe.py +++ b/utils/mocked_backend/ffe.py @@ -2,12 +2,13 @@ FLAG_SELECTION_IGNORED_HEADER, SELECTED_FLAG_KEY_PATTERNS, SELECTED_FLAG_TAGS, - UFC_TAGS_FIXTURE_RELATIVE_PATH, Selection, ) from ._backend_ffe import ( CONFIG_PATH, CONFIG_QUERY, + EVALUATION_SUITES_RELATIVE_PATH, + MockedBackendFFERequest, MockedBackendFFEStatus, EXPECTED_DD_ENV, RULES_CONFIG_PATH, @@ -23,6 +24,7 @@ __all__ = [ "CONFIG_PATH", "CONFIG_QUERY", + "EVALUATION_SUITES_RELATIVE_PATH", "EXPECTED_DD_ENV", "FLAG_SELECTION_IGNORED_HEADER", "RESPONSE_SECRET", @@ -31,7 +33,7 @@ "SELECTED_FLAG_TAGS", "UFC_FIXTURE_RELATIVE_PATH", "UFC_RESPONSE_TYPE", - "UFC_TAGS_FIXTURE_RELATIVE_PATH", + "MockedBackendFFERequest", "MockedBackendFFEStatus", "ResponseFormat", "Selection", diff --git a/utils/scripts/update_ffe_fixtures.py b/utils/scripts/update_ffe_fixtures.py index a3186ba5940..5cc5d03fab8 100644 --- a/utils/scripts/update_ffe_fixtures.py +++ b/utils/scripts/update_ffe_fixtures.py @@ -15,7 +15,7 @@ FIXTURE_REPOSITORY = "https://github.com/DataDog/ffe-system-test-data.git" FIXTURE_REF_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") SELECTION_VECTORS_PATH = Path("flag-selection/selection-vectors.json") -SELECTION_UFC_TAGS_PATH = Path("flag-selection/ufc-config-tags.json") +SUITE_FLAG_TAGS_FILE = "flag-tags.json" def _validate_fixture_ref(fixture_ref: str) -> None: @@ -110,6 +110,14 @@ def _validate_evaluation_suite(config_path: Path, cases_directory: Path) -> int: return case_count +def _validate_flag_tags(path: Path) -> None: + with path.open(encoding="utf-8") as tags_file: + content = json.load(tags_file) + if not isinstance(content, dict) or set(content) != {"flags"} or not isinstance(content["flags"], list): + msg = f"{path.parent.name}/{path.name} must contain only a flags array" + raise ValueError(msg) + + def _validate_fixture_snapshot(snapshot: Path) -> int: fixture_count = _validate_evaluation_suite(snapshot / "ufc-config.json", snapshot / "evaluation-cases") @@ -127,9 +135,15 @@ def _validate_fixture_snapshot(snapshot: Path) -> int: if not suite_directory.is_dir() or suite_directory.is_symlink(): msg = "FFE evaluation-suites must contain only suite directories" raise ValueError(msg) - if {entry.name for entry in suite_directory.iterdir()} != {"ufc-config.json", "cases"}: - msg = f"FFE feature suite {suite_directory.name} must contain only ufc-config.json and cases" + entries = {entry.name for entry in suite_directory.iterdir()} + if not {"ufc-config.json", "cases"} <= entries <= {"ufc-config.json", "cases", SUITE_FLAG_TAGS_FILE}: + msg = ( + f"FFE feature suite {suite_directory.name} must contain only ufc-config.json, cases and an optional " + f"{SUITE_FLAG_TAGS_FILE}" + ) raise ValueError(msg) + if SUITE_FLAG_TAGS_FILE in entries: + _validate_flag_tags(suite_directory / SUITE_FLAG_TAGS_FILE) fixture_count += _validate_evaluation_suite( suite_directory / "ufc-config.json", suite_directory / "cases", @@ -190,29 +204,27 @@ def _set_github_outputs(source_commit: str, fixture_count: int, *, changed: bool def _copy_flag_selection_snapshot(source: Path, snapshot: Path) -> None: - # The mocked backend applies flag selection to ufc-config.json with these vectors' semantics and tags. + # The mocked backend's flag selection unit tests run these vectors. directory = source / SELECTION_VECTORS_PATH.parent if directory.is_symlink(): msg = f"Refusing to copy symbolic link from FFE fixture repository: {directory}" raise ValueError(msg) if not directory.exists(): return - for path in (SELECTION_VECTORS_PATH, SELECTION_UFC_TAGS_PATH): - if not (source / path).exists() and not (source / path).is_symlink(): - msg = f"FFE flag-selection must contain {path.name}" - raise ValueError(msg) - _copy_entry(source / path, snapshot / path) + if not (source / SELECTION_VECTORS_PATH).exists() and not (source / SELECTION_VECTORS_PATH).is_symlink(): + msg = f"FFE flag-selection must contain {SELECTION_VECTORS_PATH.name}" + raise ValueError(msg) + _copy_entry(source / SELECTION_VECTORS_PATH, snapshot / SELECTION_VECTORS_PATH) def _validate_flag_selection_snapshot(snapshot: Path) -> None: if not (snapshot / SELECTION_VECTORS_PATH.parent).exists(): return - for path, fields in ((SELECTION_VECTORS_PATH, {"flags", "cases"}), (SELECTION_UFC_TAGS_PATH, {"flags"})): - with (snapshot / path).open(encoding="utf-8") as fixture_file: - content = json.load(fixture_file) - if not isinstance(content, dict) or set(content) != fields: - msg = f"{path.name} must contain only {', '.join(sorted(fields))}" - raise ValueError(msg) + with (snapshot / SELECTION_VECTORS_PATH).open(encoding="utf-8") as fixture_file: + content = json.load(fixture_file) + if not isinstance(content, dict) or set(content) != {"flags", "cases"}: + msg = f"{SELECTION_VECTORS_PATH.name} must contain only cases, flags" + raise ValueError(msg) def update_fixtures(fixture_ref: str) -> bool: From 4b7487c55850633f72ceeb04982d18b0274450d2 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Tue, 3 Nov 2026 23:56:00 +0000 Subject: [PATCH 07/10] test(ffe): revalidate ignored selections and check vendored selection data A request carrying a selection the mocked backend ignores got a scripted 304 whatever its If-None-Match, so an SDK that had cached a filtered body kept it instead of receiving every flag and the ignored reason. Any request with selection parameters now gets a scripted 304 only for the ETag of the body it would otherwise receive. Requests without a selection keep the unconditional scripted 304. The fixture sync also checks the entries of selection-vectors.json and a suite's flag-tags.json, not only their top-level keys, before replacing the snapshot. --- .../test_mock_ffe_flag_selection.py | 25 +++++++++++++- .../test_the_test/test_update_ffe_fixtures.py | 26 +++++++++----- utils/mocked_backend/_backend_ffe.py | 19 +++++++---- utils/scripts/update_ffe_fixtures.py | 34 +++++++++++++++++-- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py index 0ede6fe3711..d8ab5c476de 100644 --- a/tests/test_the_test/test_mock_ffe_flag_selection.py +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -287,7 +287,7 @@ def test_selection_is_not_applied_to_error_responses() -> None: try: server.client.ffe_set_responses(["server_error", "not_modified"]) for expected_status in (500, 304): - response = _get(server, f"{CONFIG_QUERY}&selected_flag_tags=%zz") + response = _get(server, f"{CONFIG_QUERY}&selected_flag_tags=%zz", {"If-None-Match": ufc_etag_for(None)}) assert response.status == expected_status assert FLAG_SELECTION_IGNORED_HEADER not in response.headers finally: @@ -323,6 +323,29 @@ def test_not_modified_under_a_selection_revalidates_only_its_own_etag() -> None: server.close() +@scenarios.test_the_test +def test_not_modified_does_not_confirm_a_selected_body_once_the_selection_is_ignored() -> None: + server = MockedBackendServer() + selection_query = f"{CONFIG_QUERY}&selected_flag_tags=team%3Acheckout" + try: + server.client.ffe_use_suite(SUITE) + server.client.ffe_set_responses(["valid", "not_modified"]) + selected_etag = _get(server, selection_query).headers["ETag"] + server.client.ffe_set_flag_selection_ignored_reason("tag_load_failed") + + response = _get(server, selection_query, {"If-None-Match": selected_etag}) + assert response.status == 200 + assert _flag_keys(response) == SUITE_FLAGS + assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == "tag_load_failed" + assert response.headers["ETag"] == ufc_etag_for(None, SUITE) + + revalidated = _get(server, selection_query, {"If-None-Match": ufc_etag_for(None, SUITE)}) + assert revalidated.status == 304 + assert FLAG_SELECTION_IGNORED_HEADER not in revalidated.headers + finally: + server.close() + + @scenarios.test_the_test def test_not_modified_without_a_selection_keeps_the_plain_etag() -> None: server = MockedBackendServer() diff --git a/tests/test_the_test/test_update_ffe_fixtures.py b/tests/test_the_test/test_update_ffe_fixtures.py index 7f6ea2261e9..99fb0a01c9e 100644 --- a/tests/test_the_test/test_update_ffe_fixtures.py +++ b/tests/test_the_test/test_update_ffe_fixtures.py @@ -8,6 +8,8 @@ from utils import scenarios from utils.scripts import update_ffe_fixtures +VALID_VECTORS = '{"flags": [{"key": "one", "tags": []}], "cases": [{"name": "case", "expected": {}}]}' + @scenarios.test_the_test @pytest.mark.parametrize("fixture_ref", ["main", "feature/fixture-update", "v1.2.3", "ea8b5cc5"]) @@ -65,7 +67,7 @@ def test_copy_flag_selection_snapshot_imports_only_vectors(tmp_path: Path) -> No snapshot = tmp_path / "snapshot" (source / "flag-selection").mkdir(parents=True) snapshot.mkdir() - (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") + (source / "flag-selection" / "selection-vectors.json").write_text(VALID_VECTORS, encoding="utf-8") (source / "flag-selection" / "README.md").write_text("upstream documentation", encoding="utf-8") update_ffe_fixtures._copy_flag_selection_snapshot(source, snapshot) @@ -110,12 +112,17 @@ def test_copy_flag_selection_snapshot_rejects_symbolic_link_directory(tmp_path: @scenarios.test_the_test def test_validate_flag_selection_snapshot_rejects_unexpected_fields(tmp_path: Path) -> None: (tmp_path / "flag-selection").mkdir() - (tmp_path / "flag-selection" / "selection-vectors.json").write_text( - '{"flags": [], "cases": [], "extra": 1}', encoding="utf-8" - ) - - with pytest.raises(ValueError, match="selection-vectors.json must contain only cases, flags"): - update_ffe_fixtures._validate_flag_selection_snapshot(tmp_path) + vectors = tmp_path / "flag-selection" / "selection-vectors.json" + for content, message in ( + ('{"flags": [], "cases": [], "extra": 1}', "must contain only cases, flags"), + ('{"flags": null, "cases": "bad"}', "flags must be a non-empty array"), + ('{"flags": [{"key": "one", "tags": [1]}], "cases": []}', "flags must be a non-empty array"), + ('{"flags": [{"key": "one", "tags": []}], "cases": []}', "cases must be a non-empty array"), + ('{"flags": [{"key": "one", "tags": []}], "cases": [{"name": "case"}]}', "cases must be a non-empty array"), + ): + vectors.write_text(content, encoding="utf-8") + with pytest.raises(ValueError, match=message): + update_ffe_fixtures._validate_flag_selection_snapshot(tmp_path) def _write_suite(snapshot: Path) -> Path: @@ -141,8 +148,9 @@ def test_validate_fixture_snapshot_accepts_suite_flag_tags(tmp_path: Path) -> No @pytest.mark.parametrize( ("name", "content", "message"), [ - ("flag-tags.json", '{"flags": {}}', "must contain only a flags array"), - ("flag-tags.json", '{"flags": [], "extra": 1}', "must contain only a flags array"), + ("flag-tags.json", '{"flags": {}}', "flags must be a non-empty array of key and tags entries"), + ("flag-tags.json", '{"flags": [{"key": "two"}]}', "flags must be a non-empty array of key and tags entries"), + ("flag-tags.json", '{"flags": [], "extra": 1}', "must contain only flags"), ("notes.json", "{}", "must contain only ufc-config.json, cases and an optional flag-tags.json"), ], ) diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index cfc9b3319a6..54ae39625ed 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -346,14 +346,17 @@ def _response_for_response( ignored_reason: str | None = None, if_none_match: str | None = None, suite: str | None = None, + *, + selection_requested: bool = False, ) -> tuple[int, bytes, dict[str, str]]: if response == "unauthorized": return HTTPStatus.UNAUTHORIZED, b"", {} if response == "malformed": return HTTPStatus.OK, MALFORMED_UFC_BYTES, {"Content-Type": "application/json"} - # Under a selection, a scripted 304 needs the selection's own validator; any other gets the selected flags. + # A request carrying a selection, applied or ignored, gets a scripted 304 only for the validator of the body it + # would get otherwise, so a 304 never confirms a body built for another selection. etag = ufc_etag_for(selection, suite) - if response == "not_modified" and (selection is None or _matches_if_none_match(if_none_match, etag)): + if response == "not_modified" and (not selection_requested or _matches_if_none_match(if_none_match, etag)): return HTTPStatus.NOT_MODIFIED, b"", {"ETag": etag} if response == "server_error": return HTTPStatus.INTERNAL_SERVER_ERROR, b"", {} @@ -479,11 +482,15 @@ def _ffe_handle_config_exec(self, request: MockedBackendRequestHandler) -> tuple if response in {"delayed_valid", "timeout"}: time.sleep(TIMEOUT_RESPONSE_SECONDS if response == "timeout" else DELAYED_RESPONSE_SECONDS) - selection, ignored_reason = _flag_selection_for_query( - urlparse(request.path).query, self._ffe_state.flag_selection_ignored_reason - ) + query = urlparse(request.path).query + selection, ignored_reason = _flag_selection_for_query(query, self._ffe_state.flag_selection_ignored_reason) status_code, body, headers = _response_for_response( - response, selection, ignored_reason, request.headers.get("If-None-Match"), self._ffe_state.suite + response, + selection, + ignored_reason, + request.headers.get("If-None-Match"), + self._ffe_state.suite, + selection_requested=has_selection_parameters(query), ) self._ffe_state.record_response(status_code) with contextlib.suppress(BrokenPipeError, ConnectionResetError): diff --git a/utils/scripts/update_ffe_fixtures.py b/utils/scripts/update_ffe_fixtures.py index 5cc5d03fab8..cde8b0e70f3 100644 --- a/utils/scripts/update_ffe_fixtures.py +++ b/utils/scripts/update_ffe_fixtures.py @@ -110,12 +110,30 @@ def _validate_evaluation_suite(config_path: Path, cases_directory: Path) -> int: return case_count +def _validate_flag_entries(flags: object, location: str) -> None: + if ( + not isinstance(flags, list) + or not flags + or not all( + isinstance(flag, dict) + and isinstance(flag.get("key"), str) + and isinstance(flag.get("tags"), list) + and all(isinstance(tag, str) for tag in flag["tags"]) + for flag in flags + ) + ): + msg = f"{location} flags must be a non-empty array of key and tags entries" + raise ValueError(msg) + + def _validate_flag_tags(path: Path) -> None: + location = f"{path.parent.name}/{path.name}" with path.open(encoding="utf-8") as tags_file: content = json.load(tags_file) - if not isinstance(content, dict) or set(content) != {"flags"} or not isinstance(content["flags"], list): - msg = f"{path.parent.name}/{path.name} must contain only a flags array" + if not isinstance(content, dict) or set(content) != {"flags"}: + msg = f"{location} must contain only flags" raise ValueError(msg) + _validate_flag_entries(content["flags"], location) def _validate_fixture_snapshot(snapshot: Path) -> int: @@ -225,6 +243,18 @@ def _validate_flag_selection_snapshot(snapshot: Path) -> None: if not isinstance(content, dict) or set(content) != {"flags", "cases"}: msg = f"{SELECTION_VECTORS_PATH.name} must contain only cases, flags" raise ValueError(msg) + _validate_flag_entries(content["flags"], SELECTION_VECTORS_PATH.name) + cases = content["cases"] + if ( + not isinstance(cases, list) + or not cases + or not all( + isinstance(case, dict) and isinstance(case.get("name"), str) and isinstance(case.get("expected"), dict) + for case in cases + ) + ): + msg = f"{SELECTION_VECTORS_PATH.name} cases must be a non-empty array of named cases with expected outcomes" + raise ValueError(msg) def update_fixtures(fixture_ref: str) -> bool: From 358e5e2439a77c623652cb707daeb0070554c287 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Thu, 5 Nov 2026 17:08:41 +0000 Subject: [PATCH 08/10] test(ffe): pin selection ETags and the revalidation query, refresh fixtures The revalidation test now also checks that the second configuration request carries the same selection as the first: the mock answers a scripted 304 to a request without a selection whatever its If-None-Match, so the ETag check alone passed for an SDK that dropped its selection. Mock tests now check that each selection and each suite gets its own ETag and that equivalent selections share one, that an extra query parameter or an unknown selection-like parameter still gets 404, that percent-encoded parameter names are selection parameters, and that the forced ignored reason is not applied to an empty selection. The fixture sync test runs with flag-selection/ present and checks that only selection-vectors.json is vendored and that an invalid file leaves the snapshot unchanged. The configuration request wait drops from 10 to 5 seconds, which bounds the cost of each test for an SDK that never fetches. Docstrings for ffe_start, If-None-Match matching and ffe_reset describe what they do. The snapshot is refreshed from DataDog/ffe-system-test-data@2083bd6, whose canonical-form case adds a key pattern that matches no flag and an empty one. --- docs/understand/scenarios/parametric.md | 2 +- .../test_ffe/ffe-system-test-data/SOURCE.md | 2 +- .../flag-selection/cases/canonical-form.json | 24 +++++++------- .../test_ffe/test_flag_selection.py | 7 ++-- .../test_mock_ffe_flag_selection.py | 32 +++++++++++++++++-- .../test_the_test/test_update_ffe_fixtures.py | 27 ++++++++++++++++ .../_test_clients/_test_client_parametric.py | 4 +-- utils/mocked_backend/_backend_ffe.py | 2 +- 8 files changed, 79 insertions(+), 21 deletions(-) diff --git a/docs/understand/scenarios/parametric.md b/docs/understand/scenarios/parametric.md index 4a901b56c0b..1df490899f8 100644 --- a/docs/understand/scenarios/parametric.md +++ b/docs/understand/scenarios/parametric.md @@ -226,7 +226,7 @@ Options use the Node helper's names (`distributionChannel`, `apiKey`, `clientTok The shared `mocked_backend` fixture serves these protobuf responses. Import shared FFE constants and fixture helpers from `utils.mocked_backend.ffe`. On the server distribution endpoint, explicitly accepting `application/protobuf` selects protobuf, including in comma-separated `Accept` lists with a positive quality value. Wildcard-only requests and existing agentless provider tests continue to receive JSON. -Use `mocked_backend.client.ffe_set_response` or `mocked_backend.client.ffe_set_responses` with `response_format="protobuf"` to configure these responses; the default format is JSON. Each format has an independent response timeline, and controls reject response IDs unsupported by the selected format. `mocked_backend.client.ffe_reset` resets both timelines and the shared request history. Use `mocked_backend.ffe_get_requests` to inspect paths, queries, and headers without including fixture control traffic. +Use `mocked_backend.client.ffe_set_response` or `mocked_backend.client.ffe_set_responses` with `response_format="protobuf"` to configure these responses; the default format is JSON. Each format has an independent response timeline, and controls reject response IDs unsupported by the selected format. `mocked_backend.client.ffe_reset` resets both timelines and the shared request history, serves the root UFC again instead of an `ffe_use_suite` suite, and clears `ffe_set_flag_selection_ignored_reason`. Use `mocked_backend.ffe_get_requests` to inspect paths, queries, and headers without including fixture control traffic. #### Effective OpenTelemetry configuration diff --git a/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md b/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md index ace6cd5f4e2..4c83ce8c3b2 100644 --- a/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md +++ b/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md @@ -3,7 +3,7 @@ These files are copied from the canonical FFE fixture repository. Canonical source: https://github.com/DataDog/ffe-system-test-data -Source commit: be2ab8372d9cce5c77fbb940266564e7b86ce83a +Source commit: 2083bd66c44424d8077c0f43485454e1b7f6e8b5 Do not edit these fixtures directly in system-tests. Add or update shared FFE behavior in ffe-system-test-data first, then refresh this snapshot. diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json index d28fb87da4f..5ee087eed16 100644 --- a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/canonical-form.json @@ -9,13 +9,13 @@ "providerOptions": { "flagSelection": { "tags": [" team:search ", "team:checkout", "team:search", ""], - "keyPatterns": ["\tops-*", "ops-*"] + "keyPatterns": ["\tops-*", "ops-*", "billing-*", ""] } }, "expectations": { "configurationRequest": { "selectedFlagTags": ["team:checkout", "team:search"], - "selectedFlagKeyPatterns": ["ops-*"] + "selectedFlagKeyPatterns": ["billing-*", "ops-*"] } }, "result": { @@ -33,13 +33,13 @@ "providerOptions": { "flagSelection": { "tags": [" team:search ", "team:checkout", "team:search", ""], - "keyPatterns": ["\tops-*", "ops-*"] + "keyPatterns": ["\tops-*", "ops-*", "billing-*", ""] } }, "expectations": { "configurationRequest": { "selectedFlagTags": ["team:checkout", "team:search"], - "selectedFlagKeyPatterns": ["ops-*"] + "selectedFlagKeyPatterns": ["billing-*", "ops-*"] } }, "result": { @@ -57,13 +57,13 @@ "providerOptions": { "flagSelection": { "tags": [" team:search ", "team:checkout", "team:search", ""], - "keyPatterns": ["\tops-*", "ops-*"] + "keyPatterns": ["\tops-*", "ops-*", "billing-*", ""] } }, "expectations": { "configurationRequest": { "selectedFlagTags": ["team:checkout", "team:search"], - "selectedFlagKeyPatterns": ["ops-*"] + "selectedFlagKeyPatterns": ["billing-*", "ops-*"] } }, "result": { @@ -81,13 +81,13 @@ "providerOptions": { "flagSelection": { "tags": [" team:search ", "team:checkout", "team:search", ""], - "keyPatterns": ["\tops-*", "ops-*"] + "keyPatterns": ["\tops-*", "ops-*", "billing-*", ""] } }, "expectations": { "configurationRequest": { "selectedFlagTags": ["team:checkout", "team:search"], - "selectedFlagKeyPatterns": ["ops-*"] + "selectedFlagKeyPatterns": ["billing-*", "ops-*"] } }, "result": { @@ -106,13 +106,13 @@ "providerOptions": { "flagSelection": { "tags": [" team:search ", "team:checkout", "team:search", ""], - "keyPatterns": ["\tops-*", "ops-*"] + "keyPatterns": ["\tops-*", "ops-*", "billing-*", ""] } }, "expectations": { "configurationRequest": { "selectedFlagTags": ["team:checkout", "team:search"], - "selectedFlagKeyPatterns": ["ops-*"] + "selectedFlagKeyPatterns": ["billing-*", "ops-*"] } }, "result": { @@ -130,13 +130,13 @@ "providerOptions": { "flagSelection": { "tags": [" team:search ", "team:checkout", "team:search", ""], - "keyPatterns": ["\tops-*", "ops-*"] + "keyPatterns": ["\tops-*", "ops-*", "billing-*", ""] } }, "expectations": { "configurationRequest": { "selectedFlagTags": ["team:checkout", "team:search"], - "selectedFlagKeyPatterns": ["ops-*"] + "selectedFlagKeyPatterns": ["billing-*", "ops-*"] } }, "result": { diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py index bb4ec31cbcf..eccadd756b2 100644 --- a/tests/parametric/test_ffe/test_flag_selection.py +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -94,7 +94,7 @@ def _agentless_env(mocked_backend: MockedBackendServer, flag_selection: object) def _config_requests(mocked_backend: MockedBackendServer, count: int) -> list[MockedBackendFFERequest]: - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 5 while len(requests := mocked_backend.ffe_get_requests()) < count and time.monotonic() < deadline: time.sleep(0.2) assert len(requests) >= count, f"the SDK made {len(requests)} configuration requests, expected {count}" @@ -183,6 +183,9 @@ def test_selected_configuration_revalidates_with_its_etag( SELECTED_FLAG_TAGS: ["team:checkout"], }, "the configuration request does not carry the selection" - assert _config_requests(mocked_backend, 2)[1]["headers"].get("if-none-match") == ufc_etag_for( + first, second = _config_requests(mocked_backend, 2)[:2] + # The mock answers a scripted 304 to a request without a selection whatever its If-None-Match. + assert _query(second) == _query(first), "the SDK dropped its selection on the revalidation poll" + assert second["headers"].get("if-none-match") == ufc_etag_for( Selection(tags=("team:checkout",), key_patterns=()), SUITE ), "the SDK did not revalidate with the ETag of its selection" diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py index d8ab5c476de..dcbf05b164c 100644 --- a/tests/test_the_test/test_mock_ffe_flag_selection.py +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -114,6 +114,7 @@ def _flag_keys(response: _Response) -> list[str]: ), ("selected_flag_tags=no-flag-has-this-tag", []), ("selected_flag_tags=%85", []), + ("selected%5Fflag%5Ftags=team%3Acheckout", CHECKOUT_FLAGS), ], ) def test_valid_selection_serves_only_selected_flags(selection_query: str, expected_keys: list[str]) -> None: @@ -235,6 +236,8 @@ def test_selection_does_not_relax_the_environment_query() -> None: for query in ( "selected_flag_tags=team%3Acheckout", "dd_env=test&dd_env=wrong&selected_flag_tags=team%3Acheckout", + "dd_env=test&other=1&selected_flag_tags=team%3Acheckout", + "dd_env=test&selected_flag_bogus=1", ): assert _get(server, query).status == 404, query finally: @@ -252,8 +255,10 @@ def test_forced_ignored_reason_applies_only_to_valid_selections() -> None: assert _flag_keys(response) == SUITE_FLAGS assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == "tag_load_failed" - response = _get_config(server) - assert FLAG_SELECTION_IGNORED_HEADER not in response.headers + for selection_query in ("", "selected_flag_tags=%20&selected_flag_key_patterns="): + response = _get_config(server, selection_query) + assert _flag_keys(response) == SUITE_FLAGS, selection_query + assert FLAG_SELECTION_IGNORED_HEADER not in response.headers, selection_query response = _get_config(server, "selected_flag_tags=%zz") assert response.headers[FLAG_SELECTION_IGNORED_HEADER] == "malformed_selection" @@ -357,3 +362,26 @@ def test_not_modified_without_a_selection_keeps_the_plain_etag() -> None: assert response.headers["ETag"] == ufc_etag_for(None), headers finally: server.close() + + +@scenarios.test_the_test +def test_each_selection_and_suite_has_its_own_etag() -> None: + server = MockedBackendServer() + queries = ( + "selected_flag_tags=team%3Acheckout", + "selected_flag_tags=team%3Aops", + "selected_flag_key_patterns=team%3Acheckout", + "selected_flag_key_patterns=search-%2A", + "selected_flag_tags=team%3Acheckout&selected_flag_key_patterns=search-%2A", + ) + try: + server.client.ffe_use_suite(SUITE) + etags = [_get_config(server, query).headers["ETag"] for query in ("", *queries)] + assert len(set(etags)) == len(etags), etags + equivalent = "selected_flag_tags=%20team%3Acheckout&selected_flag_tags=team%3Acheckout" + assert _get_config(server, equivalent).headers["ETag"] == etags[1] + + server.client.ffe_use_suite("dependent-flags") + assert _get_config(server, queries[0]).headers["ETag"] not in etags + finally: + server.close() diff --git a/tests/test_the_test/test_update_ffe_fixtures.py b/tests/test_the_test/test_update_ffe_fixtures.py index 99fb0a01c9e..0d48590c49b 100644 --- a/tests/test_the_test/test_update_ffe_fixtures.py +++ b/tests/test_the_test/test_update_ffe_fixtures.py @@ -247,3 +247,30 @@ def fake_git(working_directory: Path, *arguments: str) -> str: assert not update_ffe_fixtures.update_fixtures("main") assert (destination / "SOURCE.md").read_bytes() == metadata assert output.read_text().endswith("changed=false\n") + + +@scenarios.test_the_test +def test_update_fixtures_vendors_and_checks_selection_vectors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + source = tmp_path / "upstream" + destination = tmp_path / "destination" + _write_suite(source) + (source / "flag-selection").mkdir() + (source / "flag-selection" / "selection-vectors.json").write_text(VALID_VECTORS, encoding="utf-8") + (source / "flag-selection" / "README.md").write_text("upstream documentation", encoding="utf-8") + destination.mkdir() + + def fake_git(working_directory: Path, *arguments: str) -> str: + if arguments[0] == "checkout": + shutil.copytree(source, working_directory, dirs_exist_ok=True) + return "a" * 40 + + monkeypatch.setattr(update_ffe_fixtures, "_run_git", fake_git) + monkeypatch.setattr(update_ffe_fixtures, "FIXTURE_DESTINATION", destination) + monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "outputs")) + assert update_ffe_fixtures.update_fixtures("main") + assert [path.name for path in (destination / "flag-selection").iterdir()] == ["selection-vectors.json"] + + (source / "flag-selection" / "selection-vectors.json").write_text('{"flags": [], "cases": []}', encoding="utf-8") + with pytest.raises(ValueError, match="flags must be a non-empty array"): + update_ffe_fixtures.update_fixtures("main") + assert (destination / "flag-selection" / "selection-vectors.json").read_text(encoding="utf-8") == VALID_VECTORS diff --git a/utils/docker_fixtures/_test_clients/_test_client_parametric.py b/utils/docker_fixtures/_test_clients/_test_client_parametric.py index 15c5aa39aa5..d875017bec0 100644 --- a/utils/docker_fixtures/_test_clients/_test_client_parametric.py +++ b/utils/docker_fixtures/_test_clients/_test_client_parametric.py @@ -825,8 +825,8 @@ def ffe_fetch_rules_configuration(self, options: dict[str, Any]) -> dict[str, An def ffe_start(self, configuration: dict | None = None, *, flag_selection: object = None) -> bool: """Initialize the FFE (Feature Flagging & Experimentation) provider. - flag_selection is sent as is in the flag_selection field. The test app must pass it untouched to the SDK's - flag selection provider option: only the SDK canonicalizes and validates it. + flag_selection, unless None, is sent as is in the flag_selection field; None omits the field. The test app must + pass it untouched to the SDK's flag selection provider option: only the SDK canonicalizes and validates it. Returns: bool: True if the provider was initialized successfully, False otherwise diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index 54ae39625ed..fe16e8529f9 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -331,7 +331,7 @@ def ufc_etag_for(selection: Selection | None, suite: str | None = None) -> str: def _matches_if_none_match(if_none_match: str | None, etag: str) -> bool: - """Weak comparison against every validator in the header, like net/http's checkIfNoneMatch.""" + """'*' or a weak match with any comma-separated validator in the header.""" if if_none_match is None: return False return any( From ee7a9d0e67966646f0c906e2f225631fd34ec7c2 Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Fri, 6 Nov 2026 14:54:25 +0000 Subject: [PATCH 09/10] test(ffe): check suite case files and canonical selections against the mock A new mock unit test sends each flag-selection suite case file's expected request to the mock and checks that it serves each case's flag exactly when the case does not expect FLAG_NOT_FOUND, with the file's DD-Flag-Selection-Ignored reason. The parametric test stays gated until the test apps pass the selection, so this is the blocking check that a case file synced from ffe-system-test-data agrees with the mock. The shared-vector test also compares the mock's normalized selection with each vector's sdk.selection, so a change to trimming or ordering that leaves the selected keys unchanged still fails. Docs and docstrings now describe the mock's selection semantics as those of ffe-system-test-data flag-selection/README.md, say the forced ignored reason applies to valid, non-empty selections, state what the test apps must do with the selection, and note that the protobuf selection_ignored_* responses only add the header. The protobuf branch uses FLAG_SELECTION_IGNORED_HEADER. --- docs/mocked_backend/README.md | 2 +- .../test_ffe/test_flag_selection.py | 6 +-- .../test_mock_ffe_flag_selection.py | 40 ++++++++++++++++++- utils/mocked_backend/_backend_ffe.py | 12 +++--- utils/mocked_backend/_client.py | 2 +- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/mocked_backend/README.md b/docs/mocked_backend/README.md index 8d9e11090b2..2369ee50aff 100644 --- a/docs/mocked_backend/README.md +++ b/docs/mocked_backend/README.md @@ -23,7 +23,7 @@ One single way to observe what is sent to the Datadog backend, whatever the setu * **HTTPS on many hostnames**: `mocked-backend.invalid` and the intake hostnames derived from it (`trace.agent.`, `config.`, `instrumentation-telemetry-intake.`, `debugger-intake.`...) are network aliases of its container. Its certificate is generated when it starts, for all of them, signed by the mitmproxy CA (`utils/proxy/.mitmproxy`) the senders trust. * **Remote config**: it serves the remote config backend by default (no config until a test sends one). Tests send configs with `remote_config.send_state`, which sets them on the mocked backend, then waits for the library to apply them. See [Remote config](../edit/remote-config.md). * **Controlled responses**: `MockedBackendResponse` overrides the answer on a path, for the whole session (`MockedBackendContainer.internal_mocked_responses`) or at runtime (`client.set_mocked_responses`), like the proxy's mocked responses. -* **Feature flagging**: endpoints serving the agentless FFE configuration, with scripted responses (`client.ffe_set_response(s)`) and counters (`client.ffe_get_status`). The JSON configuration applies flag selection (`selected_flag_tags`, `selected_flag_key_patterns`) like production. `client.ffe_use_suite(name)` serves an `ffe-system-test-data` evaluation suite's UFC instead, matching tags from its `flag-tags.json`; `client.ffe_set_flag_selection_ignored_reason(...)` makes it ignore valid selections, as when production cannot load tags. +* **Feature flagging**: endpoints serving the agentless FFE configuration, with scripted responses (`client.ffe_set_response(s)`) and counters (`client.ffe_get_status`). The JSON configuration applies flag selection (`selected_flag_tags`, `selected_flag_key_patterns`) with the semantics of `ffe-system-test-data` `flag-selection/README.md`. `client.ffe_use_suite(name)` serves an `ffe-system-test-data` evaluation suite's UFC instead, matching tags from its `flag-tags.json`; `client.ffe_set_flag_selection_ignored_reason(...)` makes it ignore every valid, non-empty selection, as when production cannot load tags. * **Control endpoints**: under `/system-tests/`, never recorded, used by `MockedBackendClient` from the test runner. ## Using it in a scenario diff --git a/tests/parametric/test_ffe/test_flag_selection.py b/tests/parametric/test_ffe/test_flag_selection.py index eccadd756b2..18c69eeeea5 100644 --- a/tests/parametric/test_ffe/test_flag_selection.py +++ b/tests/parametric/test_ffe/test_flag_selection.py @@ -5,9 +5,9 @@ mocked backend serves the suite's ufc-config.json and applies the selection with the suite's flag-tags.json. Each case file is one provider start. -The selection reaches the SDK as written, through ffe_start's flag_selection field, so the canonical selection on the -request is the SDK's own work. The Node.js test app builds its provider at boot, so it takes the same raw selection -from SYSTEM_TESTS_FFE_FLAG_SELECTION instead. +The test apps must pass the selection to the SDK as written, through ffe_start's flag_selection field, so the +canonical selection on the request is the SDK's own work. The Node.js test app builds its provider at boot, so it must +take the same raw selection from SYSTEM_TESTS_FFE_FLAG_SELECTION instead. """ from dataclasses import dataclass diff --git a/tests/test_the_test/test_mock_ffe_flag_selection.py b/tests/test_the_test/test_mock_ffe_flag_selection.py index dcbf05b164c..abe62ee842a 100644 --- a/tests/test_the_test/test_mock_ffe_flag_selection.py +++ b/tests/test_the_test/test_mock_ffe_flag_selection.py @@ -37,6 +37,7 @@ json.loads((REPO_ROOT / EVALUATION_SUITES_RELATIVE_PATH / SUITE / "ufc-config.json").read_text())["flags"] ) CHECKOUT_FLAGS = ["checkout-banner", "checkout-pricing"] +SUITE_CASE_FILES = sorted((REPO_ROOT / EVALUATION_SUITES_RELATIVE_PATH / SUITE / "cases").glob("*.json")) def _encode(value: str) -> str: @@ -66,7 +67,14 @@ def test_selection_matches_shared_vectors(case: dict) -> None: if not has_selection_parameters(raw_query): assert expected["selectedKeys"] == sorted(flag_tags) return - assert select_flags(parse_selection(raw_query), flag_tags) == expected["selectedKeys"] + selection = parse_selection(raw_query) + if "selection" in case.get("sdk", {}): + # The server normalizes a selection exactly like the SDK canonicalizes it. + assert selection == Selection( + tags=tuple(case["sdk"]["selection"]["tags"]), + key_patterns=tuple(case["sdk"]["selection"]["keyPatterns"]), + ) + assert select_flags(selection, flag_tags) == expected["selectedKeys"] @dataclass @@ -385,3 +393,33 @@ def test_each_selection_and_suite_has_its_own_etag() -> None: assert _get_config(server, queries[0]).headers["ETag"] not in etags finally: server.close() + + +@scenarios.test_the_test +@pytest.mark.parametrize("case_path", SUITE_CASE_FILES, ids=[path.stem for path in SUITE_CASE_FILES]) +def test_suite_case_files_agree_with_the_mock(case_path: Path) -> None: + """For the request a suite case file expects, the mock serves each case's flag unless it expects FLAG_NOT_FOUND.""" + cases = json.loads(case_path.read_text(encoding="utf-8")) + request = cases[0]["expectations"]["configurationRequest"] + ignored_reason = cases[0].get("configurationResponse", {}).get("flagSelectionIgnored") + query = "&".join( + f"{parameter}={_encode(value)}" + for parameter, field in ( + ("selected_flag_tags", "selectedFlagTags"), + ("selected_flag_key_patterns", "selectedFlagKeyPatterns"), + ) + for value in request.get(field, []) + ) + server = MockedBackendServer() + try: + server.client.ffe_use_suite(SUITE) + if ignored_reason is not None: + server.client.ffe_set_flag_selection_ignored_reason(ignored_reason) + response = _get_config(server, query) + + assert response.headers.get(FLAG_SELECTION_IGNORED_HEADER) == ignored_reason + served = set(_flag_keys(response)) + for case in cases: + assert (case["flag"] in served) == (case["result"].get("errorCode") != "FLAG_NOT_FOUND"), case + finally: + server.close() diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index 11a5061de07..1e554e5a9c9 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -8,10 +8,12 @@ The JSON configuration serves the root UFC, or with ``mocked_backend.client.ffe_use_suite(name)`` the UFC of that ffe-system-test-data evaluation suite. It applies the ``selected_flag_tags`` and -``selected_flag_key_patterns`` query parameters like production, matching tags from the suite's -``flag-tags.json``; the root UFC has no tags. ``mocked_backend.client.ffe_set_flag_selection_ignored_reason(...)`` -makes it ignore every valid selection with that reason, as when production cannot load flag tags. -The protobuf helpers ignore both. +``selected_flag_key_patterns`` query parameters with the semantics of ffe-system-test-data +``flag-selection/README.md``, matching tags from the suite's ``flag-tags.json``; the root UFC has no tags. +``mocked_backend.client.ffe_set_flag_selection_ignored_reason(...)`` makes it ignore every valid, non-empty +selection with that reason, as when production cannot load flag tags. +The protobuf helpers ignore both; their ``selection_ignored_*`` scripted responses add a +``DD-Flag-Selection-Ignored`` header to the unfiltered configuration. """ from __future__ import annotations @@ -519,7 +521,7 @@ def _ffe_handle_rules_config( headers = {"Content-Type": "application/protobuf", "ETag": wire_fixture["rules"]["etag"]} body = base64.b64decode(wire_fixture["rules"]["response"]) if response in SELECTION_IGNORED_RESPONSES: - headers["DD-Flag-Selection-Ignored"] = SELECTION_IGNORED_RESPONSES[response] + headers[FLAG_SELECTION_IGNORED_HEADER] = SELECTION_IGNORED_RESPONSES[response] elif response == "delayed": time.sleep(4) elif response == "delayed_body": diff --git a/utils/mocked_backend/_client.py b/utils/mocked_backend/_client.py index 1f18d8226a6..951d1309425 100644 --- a/utils/mocked_backend/_client.py +++ b/utils/mocked_backend/_client.py @@ -44,7 +44,7 @@ def ffe_set_responses(self, responses: object, *, response_format: ResponseForma response.raise_for_status() def ffe_set_flag_selection_ignored_reason(self, reason: str | None) -> None: - """Ignore every valid flag selection with this DD-Flag-Selection-Ignored reason; None applies them again""" + """Ignore valid, non-empty flag selections with this DD-Flag-Selection-Ignored reason; None applies them""" response = requests.post( f"{self.base_url}{_FFE_CONTROL_PATH}/flag-selection", json={"ignored_reason": reason}, From 8654fb2d8aa4e2c98274fc2630654c91d4eb298f Mon Sep 17 00:00:00 2001 From: Mark Cai Date: Fri, 6 Nov 2026 16:10:53 +0000 Subject: [PATCH 10/10] test(ffe): refresh fixtures with the at-limits and no-match selection cases The snapshot is refreshed from DataDog/ffe-system-test-data@6457222, which adds two flag-selection case files: a selection at the 31-value and 200-byte limits with reserved URL characters and a percent-encoded request URL of about 6.8 KB, which the SDK must send, and a selection that matches no flag, which serves an empty flags object. The suite-driven parametric test and the mock agreement test pick both up. --- .../test_ffe/ffe-system-test-data/SOURCE.md | 2 +- .../cases/sdk-sends-selection-at-limits.json | 569 ++++++++++++++++++ .../cases/selection-matches-no-flag.json | 170 ++++++ 3 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-sends-selection-at-limits.json create mode 100644 tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/selection-matches-no-flag.json diff --git a/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md b/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md index f925a15e3c6..1282438e2dd 100644 --- a/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md +++ b/tests/parametric/test_ffe/ffe-system-test-data/SOURCE.md @@ -3,7 +3,7 @@ These files are copied from the canonical FFE fixture repository. Canonical source: https://github.com/DataDog/ffe-system-test-data -Source commit: 2083bd66c44424d8077c0f43485454e1b7f6e8b5 +Source commit: 64572223965cfafa8428431f61e5ed4678c1a1f0 Do not edit these fixtures directly in system-tests. Add or update shared FFE behavior in ffe-system-test-data first, then refresh this snapshot. diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-sends-selection-at-limits.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-sends-selection-at-limits.json new file mode 100644 index 00000000000..3e398065967 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/sdk-sends-selection-at-limits.json @@ -0,0 +1,569 @@ +[ + { + "description": "Selected: 31 values, 200-byte values and reserved URL characters are within the limits, and the percent-encoded request URL is under 8192 bytes, so the SDK sends the selection.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:checkout", + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:r&d+qa#1%/?=" + ], + "keyPatterns": [ + "ops-*", + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:checkout", + "team:r&d+qa#1%/?=" + ], + "selectedFlagKeyPatterns": [ + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ops-*" + ] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Selected: 31 values, 200-byte values and reserved URL characters are within the limits, and the percent-encoded request URL is under 8192 bytes, so the SDK sends the selection.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:checkout", + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:r&d+qa#1%/?=" + ], + "keyPatterns": [ + "ops-*", + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:checkout", + "team:r&d+qa#1%/?=" + ], + "selectedFlagKeyPatterns": [ + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ops-*" + ] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:checkout", + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:r&d+qa#1%/?=" + ], + "keyPatterns": [ + "ops-*", + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:checkout", + "team:r&d+qa#1%/?=" + ], + "selectedFlagKeyPatterns": [ + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ops-*" + ] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:checkout", + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:r&d+qa#1%/?=" + ], + "keyPatterns": [ + "ops-*", + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:checkout", + "team:r&d+qa#1%/?=" + ], + "selectedFlagKeyPatterns": [ + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ops-*" + ] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Selected: 31 values, 200-byte values and reserved URL characters are within the limits, and the percent-encoded request URL is under 8192 bytes, so the SDK sends the selection.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:checkout", + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:r&d+qa#1%/?=" + ], + "keyPatterns": [ + "ops-*", + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:checkout", + "team:r&d+qa#1%/?=" + ], + "selectedFlagKeyPatterns": [ + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ops-*" + ] + } + }, + "result": { + "value": true, + "reason": "STATIC" + } + }, + { + "description": "Not selected: it has no selected tag and matches no key pattern, so the server omits it.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:checkout", + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:r&d+qa#1%/?=" + ], + "keyPatterns": [ + "ops-*", + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "limit-01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-02-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-04-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-05-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-06-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-07-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-08-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-09-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-10-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-11-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-12-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-13-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-14-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-15-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-16-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-17-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-18-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-19-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-20-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-21-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-22-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-23-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-24-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-25-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-26-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-27-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-28-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "limit-29-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "team:checkout", + "team:r&d+qa#1%/?=" + ], + "selectedFlagKeyPatterns": [ + "limit-pattern-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ops-*" + ] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +] diff --git a/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/selection-matches-no-flag.json b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/selection-matches-no-flag.json new file mode 100644 index 00000000000..9937c3ced77 --- /dev/null +++ b/tests/parametric/test_ffe/ffe-system-test-data/evaluation-suites/flag-selection/cases/selection-matches-no-flag.json @@ -0,0 +1,170 @@ +[ + { + "description": "Not selected: no flag has the tag team:nobody, so the server applies the selection, returns an empty flags object and omits every flag.", + "flag": "checkout-banner", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:nobody" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "team:nobody" + ], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: no flag has the tag team:nobody, so the server applies the selection, returns an empty flags object and omits every flag.", + "flag": "checkout-pricing", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:nobody" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "team:nobody" + ], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: no flag has the tag team:nobody, so the server applies the selection, returns an empty flags object and omits every flag.", + "flag": "search-ranking", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:nobody" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "team:nobody" + ], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: no flag has the tag team:nobody, so the server applies the selection, returns an empty flags object and omits every flag.", + "flag": "summer-sale", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:nobody" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "team:nobody" + ], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: no flag has the tag team:nobody, so the server applies the selection, returns an empty flags object and omits every flag.", + "flag": "ops-kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:nobody" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "team:nobody" + ], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + }, + { + "description": "Not selected: no flag has the tag team:nobody, so the server applies the selection, returns an empty flags object and omits every flag.", + "flag": "untagged-flag", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": {}, + "providerOptions": { + "flagSelection": { + "tags": [ + "team:nobody" + ] + } + }, + "expectations": { + "configurationRequest": { + "selectedFlagTags": [ + "team:nobody" + ], + "selectedFlagKeyPatterns": [] + } + }, + "result": { + "value": false, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND" + } + } +]