diff --git a/src/workos/feature_flags/runtime/__init__.py b/src/workos/feature_flags/runtime/__init__.py new file mode 100644 index 00000000..bcc4452a --- /dev/null +++ b/src/workos/feature_flags/runtime/__init__.py @@ -0,0 +1,5 @@ +# @oagen-ignore-file +# This file is hand-maintained. The feature flags runtime client evaluates +# flags locally from a polled configuration; it is not part of the OpenAPI spec. + +"""Local evaluation of WorkOS feature flags from a polled configuration.""" diff --git a/src/workos/feature_flags/runtime/_configuration.py b/src/workos/feature_flags/runtime/_configuration.py new file mode 100644 index 00000000..6c9fb7e9 --- /dev/null +++ b/src/workos/feature_flags/runtime/_configuration.py @@ -0,0 +1,485 @@ +# @oagen-ignore-file +# This file is hand-maintained. It parses the feature flags runtime payload +# (`GET /sdk/feature-flags?payload_version=2`), which is not part of the +# OpenAPI spec. +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +import json +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Iterator, Mapping, Sequence, TypeGuard, cast + +from ..._errors import WorkOSError + +PAYLOAD_VERSION = 2 + +_CONDITIONS_KIND = "conditions" +_ONE_OF_OPERATOR = "one_of" + + +class FeatureFlagsConfigurationError(WorkOSError, ValueError): + """A feature flags payload that this SDK cannot read. + + Raised by :meth:`FeatureFlagsConfiguration.from_dict` and + :meth:`FeatureFlagsConfiguration.from_json`. When a background poll returns + such a payload, the runtime client keeps its last good configuration and + reports this error instead. + """ + + +@dataclass(frozen=True, slots=True) +class ConditionDefinition: + """One condition of a rule, as received. + + The SDK evaluates only the ``one_of`` operator. A rule containing any other + operator never matches. ``target_type`` and ``operator`` are ``None`` only + when a condition the SDK does not evaluate omits them. + """ + + target_type: str | None + operator: str | None + values: tuple[str, ...] + """Exact, case-sensitive IDs in the order received.""" + + +@dataclass(frozen=True, slots=True) +class RuleDefinition: + """One rule of a flag, as received. + + The SDK evaluates only ``conditions`` rules. A rule of any other kind, such + as one added by a newer server, never matches. + """ + + id: str + kind: str + value: bool | None + """Served when the rule matches. ``None`` when the served value is not a boolean.""" + conditions: tuple[ConditionDefinition, ...] + """Conditions that must all match. Best effort for kinds the SDK does not evaluate.""" + + +@dataclass(frozen=True, slots=True) +class FlagDefinition: + """One flag's configuration as the runtime client received it. + + For inspection and debugging only. Use the runtime client's ``is_enabled`` + for decisions. + """ + + slug: str + enabled: bool + """Whether the flag is on in this environment.""" + default_value: bool | None + """Served when the flag is on and no rule matches. ``None`` when not a boolean.""" + off_value: bool | None + """Served when the flag is off. ``None`` when not a boolean.""" + rules: tuple[RuleDefinition, ...] + """Rules in evaluation order. The first matching rule wins.""" + + +@dataclass(frozen=True, slots=True) +class _CompiledCondition: + target_type: str + ids: frozenset[str] + + +@dataclass(frozen=True, slots=True) +class _CompiledRule: + # Empty when the rule can never match: an unknown kind, an unknown + # operator, a condition without a target type, or no conditions at all. + conditions: tuple[_CompiledCondition, ...] + value: bool | None + + +@dataclass(frozen=True, slots=True) +class _CompiledFlag: + definition: FlagDefinition + rules: tuple[_CompiledRule, ...] + canonical: str + + def evaluate(self, targets: Mapping[str, str]) -> bool | None: + """Return the served boolean, or ``None`` when the flag has no answer.""" + definition = self.definition + if not definition.enabled: + return definition.off_value + for rule in self.rules: + if rule.conditions and all( + targets.get(condition.target_type) in condition.ids + for condition in rule.conditions + ): + return rule.value + return definition.default_value + + +_NEVER_MATCHES = _CompiledRule(conditions=(), value=None) + + +class FeatureFlagsConfiguration: + """An immutable, validated snapshot of an environment's feature flags. + + Persist :meth:`to_json` in your own storage and pass + ``FeatureFlagsConfiguration.from_json(cached)`` as ``bootstrap`` to make a + new runtime client usable before its first successful poll. + """ + + __slots__ = ("_json", "_fingerprint", "_flags") + + _json: str + # The payload serialized with sorted keys: equal for equal content, so an + # unchanged poll can keep this snapshot without parsing it again. + _fingerprint: str + _flags: Mapping[str, _CompiledFlag] + + def __init__(self, payload: Mapping[str, Any]) -> None: + """Validate a complete version 2 payload. + + Raises: + FeatureFlagsConfigurationError: If the payload is malformed or uses + another payload version. Unknown rule kinds and operators are + accepted and never match, and a served value that is not a + boolean leaves the flag without an answer. + """ + self._initialize(payload, _fingerprint(payload)) + + def _initialize(self, payload: object, fingerprint: str) -> None: + self._json = _dumps(payload, sort_keys=False) + self._fingerprint = fingerprint + self._flags = MappingProxyType(_parse_payload(json.loads(self._json))) + + def __reduce__(self) -> tuple[Any, ...]: + # MappingProxyType cannot be pickled; the JSON rebuilds an equal snapshot. + return (_restore, (self._json,)) + + def __copy__(self) -> FeatureFlagsConfiguration: + return self + + def __deepcopy__(self, memo: dict[int, Any]) -> FeatureFlagsConfiguration: + return self + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> FeatureFlagsConfiguration: + """Validate a decoded version 2 payload, e.g. one you cached with :meth:`to_dict`.""" + return cls(data) + + @classmethod + def from_json(cls, text: str | bytes) -> FeatureFlagsConfiguration: + """Validate a version 2 payload serialized as JSON, e.g. one you cached with :meth:`to_json`.""" + try: + data: object = json.loads(text) + except ValueError as error: + raise FeatureFlagsConfigurationError( + f"Feature flags configuration is not valid JSON: {error}" + ) from error + if not _is_mapping(data): + raise FeatureFlagsConfigurationError( + "Expected an object for the feature flags configuration." + ) + return cls(data) + + def to_dict(self) -> dict[str, Any]: + """Return a copy of the complete payload, including fields this SDK ignores.""" + return cast(dict[str, Any], json.loads(self._json)) + + def to_json(self) -> str: + """Return the complete payload as JSON, including fields this SDK ignores.""" + return self._json + + def get_flag(self, slug: str) -> FlagDefinition | None: + """Return one flag's configuration, or ``None`` if the snapshot does not contain it.""" + flag = self._compiled(slug) + return flag.definition if flag is not None else None + + @property + def slugs(self) -> tuple[str, ...]: + """Every flag slug in this snapshot, in payload order.""" + return tuple(self._flags) + + def __len__(self) -> int: + return len(self._flags) + + def __contains__(self, slug: object) -> bool: + return isinstance(slug, str) and slug in self._flags + + def __iter__(self) -> Iterator[str]: + return iter(self._flags) + + def __repr__(self) -> str: + return f"FeatureFlagsConfiguration(flags={len(self._flags)})" + + def _compiled(self, slug: object) -> _CompiledFlag | None: + return self._flags.get(slug) if _is_string(slug) else None + + def _compiled_items(self) -> Iterator[tuple[str, _CompiledFlag]]: + return iter(self._flags.items()) + + +def configuration_for( + payload: object, current: FeatureFlagsConfiguration | None +) -> FeatureFlagsConfiguration: + """``current`` when ``payload`` has the same content, otherwise a new snapshot. + + Most polls return an unchanged configuration; reusing the snapshot skips + validating it again. Key order does not count as a change. + """ + fingerprint = _fingerprint(payload) + if current is not None and current._fingerprint == fingerprint: + return current + configuration = FeatureFlagsConfiguration.__new__(FeatureFlagsConfiguration) + configuration._initialize(payload, fingerprint) + return configuration + + +def changed_flags( + previous: FeatureFlagsConfiguration | None, current: FeatureFlagsConfiguration +) -> frozenset[str]: + """Slugs added, removed or modified between two snapshots. + + Every slug counts as changed on a first load. A flag's complete content is + compared, including fields this SDK does not interpret, so a cache kept + fresh from change notifications never misses a newer server's fields. + """ + if previous is None: + return frozenset(current.slugs) + if previous is current: + return frozenset() + changed = { + slug + for slug, flag in current._compiled_items() + if (before := previous._compiled(slug)) is None + or before.canonical != flag.canonical + } + changed.update(slug for slug in previous.slugs if slug not in current) + return frozenset(changed) + + +def _restore(text: str) -> FeatureFlagsConfiguration: + return FeatureFlagsConfiguration.from_json(text) + + +# --- Parsing ------------------------------------------------------------------ + + +def _dumps(payload: object, *, sort_keys: bool) -> str: + try: + return json.dumps( + payload, + sort_keys=sort_keys, + ensure_ascii=False, + separators=(",", ":"), + default=_mapping_as_dict, + ) + except (TypeError, ValueError) as error: + raise FeatureFlagsConfigurationError( + f"Feature flags configuration is not JSON-serializable: {error}" + ) from error + + +def _mapping_as_dict(value: object) -> object: + # json only serializes dicts, but the payload may be any mapping, such as a + # read-only MappingProxyType of a cached configuration. + if isinstance(value, Mapping): + return dict(cast(Mapping[object, object], value)) + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + +def _fingerprint(payload: object) -> str: + return _dumps(payload, sort_keys=True) + + +def _invalid(message: str) -> FeatureFlagsConfigurationError: + return FeatureFlagsConfigurationError( + f"Invalid feature flags configuration: {message}" + ) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[str, Any]]: + return isinstance(value, Mapping) + + +def _is_list(value: object) -> TypeGuard[list[Any]]: + return isinstance(value, list) + + +def _is_string(value: object) -> TypeGuard[str]: + return isinstance(value, str) + + +def _is_nonempty_string(value: object) -> TypeGuard[str]: + return isinstance(value, str) and value != "" + + +def _served_value(value: object) -> bool | None: + # Mirrors the server: a served value that is not a boolean, such as a + # future multivariate variant, means "no answer" rather than false, so the + # caller's own default applies. + return value if isinstance(value, bool) else None + + +def _parse_payload(payload: object) -> dict[str, _CompiledFlag]: + if not _is_mapping(payload): + raise _invalid("expected an object.") + version = payload.get("version") + # `bool` is a subclass of `int`, and `2.0 == 2`, so compare the exact type. + if type(version) is not int or version != PAYLOAD_VERSION: + raise _invalid(f"expected SDK payload version {PAYLOAD_VERSION}.") + flags = payload.get("flags") + if not _is_mapping(flags): + raise _invalid("expected an object for flags.") + return {slug: _parse_flag(slug, entry) for slug, entry in flags.items()} + + +def _parse_flag(slug: str, entry: object) -> _CompiledFlag: + if not _is_mapping(entry): + raise _invalid(f"expected an object for flag {slug!r}.") + # The map key is the flag's identity. A `slug` that differs from it is + # tolerated, as the Node SDK does, rather than failing the whole refresh. + if not isinstance(entry.get("slug"), str): + raise _invalid(f"expected a string slug for flag {slug!r}.") + enabled = entry.get("enabled") + if not isinstance(enabled, bool): + raise _invalid(f"expected a boolean for {slug!r}.enabled.") + raw_rules = entry.get("rules") + if not _is_list(raw_rules): + raise _invalid(f"expected an array for {slug!r}.rules.") + parsed = [_parse_rule(slug, rule) for rule in raw_rules] + definition = FlagDefinition( + slug=slug, + enabled=enabled, + default_value=_served_value(entry.get("default_value")), + off_value=_served_value(entry.get("off_value")), + rules=tuple(definition for definition, _ in parsed), + ) + return _CompiledFlag( + definition=definition, + rules=tuple(compiled for _, compiled in parsed), + canonical=_canonical_flag(entry), + ) + + +def _parse_rule(slug: str, rule: object) -> tuple[RuleDefinition, _CompiledRule]: + if not _is_mapping(rule): + raise _invalid(f"expected an object for a rule of {slug!r}.") + rule_id = rule.get("id") + kind = rule.get("kind") + if not _is_nonempty_string(rule_id) or not _is_nonempty_string(kind): + raise _invalid(f"expected a nonempty id and kind for every rule of {slug!r}.") + value = _served_value(rule.get("value")) + if kind != _CONDITIONS_KIND: + # A future rule kind may have entirely different fields. Record what is + # recognizable for inspection but never validate or evaluate it, so a + # newer server can never fail this SDK's refresh. + return ( + RuleDefinition( + rule_id, kind, value, _inspect_conditions(rule.get("conditions")) + ), + _NEVER_MATCHES, + ) + raw_conditions = rule.get("conditions") + if raw_conditions is None: + return RuleDefinition(rule_id, kind, value, ()), _NEVER_MATCHES + if not _is_list(raw_conditions): + raise _invalid(f"expected an array of conditions for rule {rule_id!r}.") + parsed = [_parse_condition(rule_id, condition) for condition in raw_conditions] + definitions = tuple(definition for definition, _ in parsed) + compiled = tuple(condition for _, condition in parsed if condition is not None) + evaluable = bool(parsed) and len(compiled) == len(parsed) + return ( + RuleDefinition(rule_id, kind, value, definitions), + _CompiledRule(compiled, value) if evaluable else _NEVER_MATCHES, + ) + + +def _parse_condition( + rule_id: str, condition: object +) -> tuple[ConditionDefinition, _CompiledCondition | None]: + if not _is_mapping(condition): + raise _invalid(f"expected an object for a condition of rule {rule_id!r}.") + operator = condition.get("operator") + if not isinstance(operator, str): + raise _invalid(f"expected a string operator in rule {rule_id!r}.") + if operator != _ONE_OF_OPERATOR: + # Future operators may carry different operands. They are recorded for + # inspection but never validated, and the rule containing them never + # matches. + return _inspect_condition(condition), None + target_type = condition.get("target_type") + if target_type is not None and not isinstance(target_type, str): + raise _invalid(f"expected a string target_type in rule {rule_id!r}.") + raw_values = condition.get("values") + if not _is_list(raw_values): + raise _invalid( + f"expected an array of values for a one_of condition in rule {rule_id!r}." + ) + values = _string_values(raw_values) + definition = ConditionDefinition(target_type, operator, values) + if target_type is None: + return definition, None + # IDs that are not strings can never equal a context ID, so they are + # dropped rather than failing the whole refresh. + return definition, _CompiledCondition(target_type, frozenset(values)) + + +def _inspect_conditions(raw: object) -> tuple[ConditionDefinition, ...]: + if not _is_list(raw): + return () + return tuple( + _inspect_condition(condition) for condition in raw if _is_mapping(condition) + ) + + +def _inspect_condition(condition: Mapping[str, Any]) -> ConditionDefinition: + target_type = condition.get("target_type") + operator = condition.get("operator") + return ConditionDefinition( + target_type if isinstance(target_type, str) else None, + operator if isinstance(operator, str) else None, + _string_values(condition.get("values")), + ) + + +def _string_values(raw: object) -> tuple[str, ...]: + if not _is_list(raw): + return () + return tuple(value for value in raw if isinstance(value, str)) + + +# --- Change detection --------------------------------------------------------- + + +def _canonical_flag(entry: Mapping[str, Any]) -> str: + # Property order never matters. Rule order does (first match wins), but + # the conditions of a `conditions` rule must all match, so their order does + # not, and the IDs of a `one_of` condition form a set. Every other array + # stays order-sensitive: a spurious change only costs a redundant + # notification, but a missed one leaves a cache stale. + canonical = dict(entry) + rules = canonical.get("rules") + if _is_list(rules): + canonical["rules"] = [_canonical_rule(rule) for rule in rules] + return json.dumps( + canonical, sort_keys=True, ensure_ascii=False, separators=(",", ":") + ) + + +def _canonical_rule(rule: object) -> object: + if not _is_mapping(rule) or rule.get("kind") != _CONDITIONS_KIND: + return rule + conditions = rule.get("conditions") + if not _is_list(conditions): + return rule + ordered = sorted( + (_canonical_condition(condition) for condition in conditions), + key=lambda condition: json.dumps(condition, sort_keys=True, ensure_ascii=False), + ) + return {**rule, "conditions": ordered} + + +def _canonical_condition(condition: object) -> object: + if not _is_mapping(condition) or condition.get("operator") != _ONE_OF_OPERATOR: + return condition + values = condition.get("values") + if not _is_list(values) or not all(isinstance(value, str) for value in values): + return condition + return {**condition, "values": sorted(set(cast(Sequence[str], values)))} diff --git a/src/workos/feature_flags/runtime/_context.py b/src/workos/feature_flags/runtime/_context.py new file mode 100644 index 00000000..7920155a --- /dev/null +++ b/src/workos/feature_flags/runtime/_context.py @@ -0,0 +1,134 @@ +# @oagen-ignore-file +# This file is hand-maintained. It validates the evaluation context passed to +# the feature flags runtime client. +# pyright: strict + +from __future__ import annotations + +import logging +import re +import threading +from typing import Mapping, Optional, Union, cast + +EvaluationContext = Mapping[str, Optional[str]] +"""Maps a target type to the exact ID being evaluated. + +For example ``{"user": "user_123", "organization": "org_123", "workspace": "ws_1"}``. +``user`` and ``organization`` are target types like any custom type. Entries +whose ID is ``None`` are skipped, so optional IDs can be passed directly. +""" + +LoggerLike = Union[logging.Logger, "logging.LoggerAdapter[logging.Logger]"] + +# Mirrors the API's validation rules for target types and IDs. `fullmatch` is +# required: `$` in `re.match` also matches before a trailing newline. +_TARGET_TYPE = re.compile(r"[a-z][a-z0-9_-]{0,63}") +_TARGET_ID = re.compile(r"[A-Za-z0-9._:-]{1,255}") +_RESERVED_IDS = frozenset({".", ".."}) + +# Spellings from other SDKs or Python habit that name a built-in target type. +_BUILT_IN_HINTS = { + "userId": "user", + "user_id": "user", + "organizationId": "organization", + "organization_id": "organization", +} + +_MAX_DISTINCT_WARNINGS = 100 + + +class ContextWarnings: + """Logs each distinct context problem once per runtime client. + + Evaluation runs on hot paths, so a recurring mistake must not log on every + call. The number of remembered problems is bounded. + """ + + def __init__(self, logger: LoggerLike) -> None: + self._logger = logger + self._seen: set[tuple[str, str]] = set() + self._lock = threading.Lock() + self._saturated = False + + def warn(self, reason: str, key: str, message: str) -> None: + with self._lock: + if (reason, key) in self._seen: + return + if len(self._seen) >= _MAX_DISTINCT_WARNINGS: + if self._saturated: + return + self._saturated = True + message = ( + "Too many distinct evaluation context problems; " + "further ones are not logged" + ) + else: + self._seen.add((reason, key)) + self._logger.warning(message) + + +def normalize_context(context: object, warnings: ContextWarnings) -> dict[str, str]: + """Reduce an evaluation context to valid target type to ID pairs. + + Evaluation must never raise in application code, so every invalid piece of + context is ignored with a logged warning and the rest is still evaluated. + """ + if context is None: + return {} + if not isinstance(context, Mapping): + warnings.warn( + "context", + type(context).__name__, + "Ignoring invalid evaluation context: expected a mapping of target type to ID, " + f"got {type(context).__name__}", + ) + return {} + targets: dict[str, str] = {} + for key, value in cast(Mapping[object, object], context).items(): + if value is None: + continue + target_type = _valid_target_type(key, warnings) + if target_type is not None and _is_valid_id(target_type, value, warnings): + targets[target_type] = cast(str, value) + return targets + + +def _valid_target_type(key: object, warnings: ContextWarnings) -> str | None: + if not isinstance(key, str) or _TARGET_TYPE.fullmatch(key) is None: + label = key if isinstance(key, str) else type(key).__name__ + hint = _BUILT_IN_HINTS.get(label) + suffix = f"; use {hint!r}" if hint else "" + warnings.warn( + "type", + label, + f"Ignoring invalid target type in evaluation context: {label!r}{suffix}", + ) + return None + hint = _BUILT_IN_HINTS.get(key) + if hint is not None: + warnings.warn( + "hint", + key, + f"Evaluation context key {key!r} is treated as a custom target type; " + f"use {hint!r} to match {hint} targets", + ) + return key + + +def _is_valid_id(target_type: str, value: object, warnings: ContextWarnings) -> bool: + if isinstance(value, str): + if _TARGET_ID.fullmatch(value) is not None and value not in _RESERVED_IDS: + return True + warnings.warn( + "id", + target_type, + f"Ignoring invalid target id in evaluation context for type: {target_type!r}", + ) + return False + warnings.warn( + "id-type", + target_type, + f"Ignoring target type {target_type!r} in evaluation context: expected a string ID, " + f"got {type(value).__name__}", + ) + return False diff --git a/tests/feature_flags_runtime/__init__.py b/tests/feature_flags_runtime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/feature_flags_runtime/fixtures/change_cases.json b/tests/feature_flags_runtime/fixtures/change_cases.json new file mode 100644 index 00000000..25c90f81 --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/change_cases.json @@ -0,0 +1,958 @@ +{ + "description": "Change detection between two valid payloads. changed lists the slugs a change notification reports. A case without before starts from the top-level baseline payload.", + "format": 1, + "baseline": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "cases": [ + { + "name": "rule order", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "rule value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "membership", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_2" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "off value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": true, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "default value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "enabled", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "rule kind", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "rollout", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "condition operator", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "not_one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "target type", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "removed rule", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "membership and condition order do not count", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1", + "user_2" + ] + }, + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + } + ] + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + }, + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_2", + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [] + }, + { + "name": "additions and removals", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "added": { + "slug": "added", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "added", + "flag" + ] + }, + { + "name": "flag and property order do not count", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "alpha": { + "slug": "alpha", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + }, + "beta": { + "slug": "beta", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + } + } + }, + "after": { + "version": 2, + "flags": { + "beta": { + "rules": [], + "slug": "beta", + "off_value": false, + "default_value": true, + "enabled": true + }, + "alpha": { + "slug": "alpha", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + } + } + }, + "changed": [] + }, + { + "name": "a field of an unknown rule kind counts", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r", + "kind": "rollout", + "value": true, + "percentage": 10 + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r", + "kind": "rollout", + "value": true, + "percentage": 20 + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "two non-boolean served values count", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": "a", + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": "b", + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "values of an unknown operator keep their order", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "in_order", + "values": [ + "a", + "b" + ] + } + ] + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "in_order", + "values": [ + "b", + "a" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "an unknown flag field counts", + "source": "workos-python (new)", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ], + "rollout_seed": "abc" + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "identical payloads", + "source": "workos-python (new)", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [] + } + ] +} diff --git a/tests/feature_flags_runtime/fixtures/context_cases.json b/tests/feature_flags_runtime/fixtures/context_cases.json new file mode 100644 index 00000000..d1470185 --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/context_cases.json @@ -0,0 +1,204 @@ +{ + "description": "Evaluation context validation. targets is what remains after invalid entries are ignored; warns says whether a warning is logged. Validation never raises.", + "format": 1, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + } + ] + }, + { + "id": "r2", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "cases": [ + { + "name": "a valid custom target", + "context": { + "workspace": "ws_1" + }, + "targets": { + "workspace": "ws_1" + }, + "warns": false + }, + { + "name": "built-in types are plain keys", + "context": { + "user": "user_1", + "organization": "org_1" + }, + "targets": { + "user": "user_1", + "organization": "org_1" + }, + "warns": false + }, + { + "name": "null IDs are skipped silently", + "context": { + "user": null, + "workspace": "ws_1" + }, + "targets": { + "workspace": "ws_1" + }, + "warns": false + }, + { + "name": "an uppercase type is ignored", + "context": { + "Workspace": "ws_1" + }, + "targets": {}, + "warns": true + }, + { + "name": "a type starting with a digit is ignored", + "context": { + "1workspace": "ws_1" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 65-character type is ignored", + "context": { + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "ws_1" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 64-character type is accepted", + "context": { + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "ws_1" + }, + "targets": { + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "ws_1" + }, + "warns": false + }, + { + "name": "a dot ID is ignored", + "context": { + "workspace": "." + }, + "targets": {}, + "warns": true + }, + { + "name": "a dot-dot ID is ignored", + "context": { + "workspace": ".." + }, + "targets": {}, + "warns": true + }, + { + "name": "an ID with a space is ignored", + "context": { + "workspace": "ws 1" + }, + "targets": {}, + "warns": true + }, + { + "name": "an ID with a trailing newline is ignored", + "context": { + "workspace": "ws_1\n" + }, + "targets": {}, + "warns": true + }, + { + "name": "an empty ID is ignored", + "context": { + "workspace": "" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 256-character ID is ignored", + "context": { + "workspace": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 255-character ID is accepted", + "context": { + "workspace": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "targets": { + "workspace": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "warns": false + }, + { + "name": "a non-string ID is ignored", + "context": { + "workspace": 1 + }, + "targets": {}, + "warns": true + }, + { + "name": "an object ID is ignored", + "context": { + "workspace": { + "id": "ws_1" + } + }, + "targets": {}, + "warns": true + }, + { + "name": "invalid entries do not affect valid ones", + "context": { + "Workspace": "ws_1", + "user": "user_1" + }, + "targets": { + "user": "user_1" + }, + "warns": true + }, + { + "name": "a non-ASCII ID is ignored", + "context": { + "workspace": "wś_1" + }, + "targets": {}, + "warns": true + } + ] +} diff --git a/tests/feature_flags_runtime/fixtures/evaluation_cases.json b/tests/feature_flags_runtime/fixtures/evaluation_cases.json new file mode 100644 index 00000000..fe21e6ee --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/evaluation_cases.json @@ -0,0 +1,2352 @@ +{ + "description": "Feature flag evaluation cases shaped for the shared cross-SDK corpus (AUTH-7098). Each case is one v2 flag entry; build the payload {version: 2, flags: {: flag}}, where key defaults to the flag's slug, and evaluate by that key. context maps a target type to an exact ID. An evaluation may carry a name describing what it checks. expect is true, false, or \"absent\": the flag has no boolean answer, so the caller's default applies. A runner must evaluate every case with both caller defaults and fail on unknown case fields. malformed marks cases that deliberately leave the v2 schema.", + "format": 1, + "cases": [ + { + "name": "rules are evaluated in payload order and the first match wins", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "name": "an earlier matching false rule wins and stops evaluation", + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": false + }, + { + "name": "a later rule matches when earlier rules do not", + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "name": "no match serves the flag default, not the caller default", + "context": {}, + "expect": true + } + ] + }, + { + "name": "off value overrides matching rules and both defaults", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "a boolean off value is honored", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "off_value": true, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "an empty rule list serves the flag default", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "membership matches only the exact ID of the same target type", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "name": "a missing context type does not match", + "context": {}, + "expect": false + }, + { + "name": "membership uses exact, case-sensitive IDs", + "context": { + "user": "USER_1" + }, + "expect": false + }, + { + "name": "an ID from another type does not match", + "context": { + "workspace": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "reordering conflicting target types changes the winner", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": true + } + ] + }, + { + "name": "a later rule can match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "custom target membership can match any listed ID", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_0", + "ws_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "workspace": "ws_1" + }, + "expect": true + }, + { + "context": { + "workspace": "ws_2" + }, + "expect": false + } + ] + }, + { + "name": "a rule matches only when every condition matches", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + }, + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "name": "all conditions must match", + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": true + }, + { + "name": "one matching condition cannot override a failed condition", + "context": { + "user": "user_1", + "organization": "org_other" + }, + "expect": false + }, + { + "name": "one matching condition cannot override a missing context type", + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "empty conditions never match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "user": "user_1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "empty membership never matches", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "unknown rule kinds are skipped even with matching conditions", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "rollout", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "unknown rules without conditions allow later rules to match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "future", + "kind": "rollout", + "value": false + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ], + "malformed": true + }, + { + "name": "unknown operators invalidate the whole rule", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + }, + { + "operator": "segment" + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "unknown operators allow later rules to match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "not_one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ], + "malformed": true + }, + { + "name": "unparseable matching values use the caller default and stop", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": "true", + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "unparseable off values use the caller default", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": false, + "off_value": 0, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "unparseable defaults use the caller default", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": "false", + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "unparseable values in unmatched rules have no effect", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": null, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_other" + }, + "expect": true + }, + { + "context": { + "organization": "org_1" + }, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "membership IDs that are not strings never match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + 1 + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "a disabled flag serves its off value", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "disabled-flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "user": "user_456" + }, + "expect": false + } + ] + }, + { + "name": "an enabled flag without rules serves its default", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "enabled-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": true + }, + { + "context": { + "user": "user_456" + }, + "expect": true + } + ] + }, + { + "name": "targeted flag matches built-in and custom types", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "targeted-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_organization", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_123" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + }, + { + "id": "rule_workspace", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_123" + ] + } + ] + }, + { + "id": "rule_region", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "region", + "operator": "one_of", + "values": [ + "us-east-1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_123" + }, + "expect": true + }, + { + "context": { + "user": "user_456" + }, + "expect": true + }, + { + "context": { + "user": "user_other", + "organization": "org_123" + }, + "expect": true + }, + { + "context": { + "workspace": "ws_123" + }, + "expect": true + }, + { + "context": { + "region": "us-east-1" + }, + "expect": true + }, + { + "context": { + "workspace": "ws_456" + }, + "expect": false + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "served values: off value wins even if a rule matches", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": false, + "off_value": true, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": true + } + ] + }, + { + "name": "served values: default when no rule matches", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_other" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": true + } + ] + }, + { + "name": "served values: first matching rule when it is true", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + }, + { + "id": "rule_organization", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_123" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": true + } + ] + }, + { + "name": "served values: first matching rule when it is false", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + }, + { + "id": "rule_organization", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_123" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": false + } + ] + }, + { + "name": "ordered: first false rule beats a later true rule and a true default", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "ordered", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + } + ] + }, + { + "id": "r2", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_jp" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": false + }, + { + "context": {}, + "expect": true + }, + { + "context": { + "user": "user_jp" + }, + "expect": true + } + ] + }, + { + "name": "disabled: serves off_value even if a rule matches", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "disabled", + "enabled": false, + "default_value": false, + "off_value": true, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": true + } + ] + }, + { + "name": "both: every condition must match", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "both", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + }, + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_jp" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": true + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + }, + { + "context": { + "user": "user_jp" + }, + "expect": false + }, + { + "context": { + "user": "USER_JP", + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "custom: custom target types match exact IDs", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "custom", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "project", + "operator": "one_of", + "values": [ + "project_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": false + }, + { + "context": { + "project": "project_1" + }, + "expect": true + }, + { + "context": { + "project": "different" + }, + "expect": false + } + ] + }, + { + "name": "variant_default: a non-boolean default has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "variant_default", + "enabled": true, + "default_value": "variant-a", + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "variant_off: a non-boolean off value has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "variant_off", + "enabled": false, + "default_value": true, + "off_value": "variant-off", + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "variant_rule: only a matched non-boolean rule has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "variant_rule", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": "variant-b", + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_acme" + }, + "expect": "absent" + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "missing_default: a missing default_value has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "missing_default", + "enabled": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "unknown kind without conditions never matches everyone", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "percentage", + "value": true + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "empty condition list never matches everyone", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "unknown operator never matches everyone", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "future" + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "empty one_of membership never matches", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "server: off consults no rules", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1" + }, + "expect": false + } + ] + }, + { + "name": "server: off value disagrees with every rule", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": false, + "default_value": false, + "off_value": true, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "server: default when no rules", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "server: default when no rule matches", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_2" + }, + "expect": true + } + ] + }, + { + "name": "server: a rule whose target type is absent is skipped", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "server: user rule first", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_org", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1", + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "server: organization rule first", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1", + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "server: passes a non-matching rule to reach a later one", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_other" + ] + } + ] + }, + { + "id": "rule_2", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1" + }, + "expect": true + } + ] + }, + { + "name": "server: matches an ID anywhere in the values", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1", + "org_2", + "org_3" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_2" + }, + "expect": true + } + ] + }, + { + "name": "server: custom type matches like a built-in one", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "workspace": "ws_1" + }, + "expect": true + } + ] + }, + { + "name": "server: every condition must match", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + }, + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1", + "user": "user_other" + }, + "expect": false + }, + { + "context": { + "organization": "org_1" + }, + "expect": false + }, + { + "context": { + "organization": "org_1", + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "server: an empty condition list never matches", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "malformed": true, + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1" + }, + "expect": false + } + ] + }, + { + "name": "server: unknown flag fields are ignored", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [], + "rollout_seed": "abc" + }, + "evaluations": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "server: unknown rule fields are ignored", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ], + "percentage": 50 + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "an unknown kind with an unfamiliar condition shape is skipped, not rejected", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "rollout", + "value": true, + "conditions": [ + { + "attribute": "plan", + "equals": "pro" + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "a conditions rule without conditions never matches", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "a one_of condition without a target type never matches", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "a missing off_value has no answer when the flag is off", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "duplicate IDs in a membership list still match", + "source": "workos-python (new)", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1", + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "a flag is looked up by its map key when its slug differs", + "source": "workos-python (new)", + "malformed": true, + "key": "renamed-flag", + "flag": { + "slug": "lab-ordered", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "IDs with every allowed punctuation character match exactly", + "source": "workos-python (new)", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "deploy-env_v2", + "operator": "one_of", + "values": [ + "a.b_c:d-e" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "deploy-env_v2": "a.b_c:d-e" + }, + "expect": true + }, + { + "context": { + "deploy-env_v2": "a.b_c:d-E" + }, + "expect": false + } + ] + } + ] +} diff --git a/tests/feature_flags_runtime/fixtures/payload_cases.json b/tests/feature_flags_runtime/fixtures/payload_cases.json new file mode 100644 index 00000000..fda0c8dd --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/payload_cases.json @@ -0,0 +1,761 @@ +{ + "description": "Whole-payload validation. valid=false means the payload is rejected and a runtime client keeps its last good configuration. divergence records where Node 12 or .NET 6.6 answers differently.", + "format": 1, + "cases": [ + { + "name": "a populated v2 payload", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": true + }, + { + "name": "an empty v2 payload", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": {} + }, + "valid": true + }, + { + "name": "extra top-level and flag keys are ignored", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ], + "future": 1 + } + }, + "future": { + "version": 9 + } + }, + "valid": true + }, + { + "name": "a future version", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 3, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a string version", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": "2", + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a missing version", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "version 1", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 1, + "flags": {} + }, + "valid": false + }, + { + "name": "a missing flags map", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 2 + }, + "valid": false + }, + { + "name": "null flags", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": null + }, + "valid": false + }, + { + "name": "null rules", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": null + } + } + }, + "valid": false + }, + { + "name": "the legacy v1 flat payload", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "targets": { + "users": [ + { + "id": "user_1", + "enabled": true + } + ], + "organizations": [] + } + } + }, + "valid": false + }, + { + "name": "a bare entry map", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + }, + "valid": false + }, + { + "name": "a one_of with a string instead of a list", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": "user_1" + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a one_of without values", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of" + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "null", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": null, + "valid": false + }, + { + "name": "an array", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": [], + "valid": false + }, + { + "name": "a scalar", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": false, + "valid": false + }, + { + "name": "a string enabled", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 2, + "flags": { + "test": { + "slug": "test", + "enabled": "true", + "default_value": false, + "off_value": false, + "rules": [] + } + } + }, + "valid": false + }, + { + "name": "a slug that differs from its key is accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "test": { + "slug": "other", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [] + } + } + }, + "valid": true, + "note": "the map key is the flag's identity", + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "a missing slug", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "test": { + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [] + } + } + }, + "valid": false + }, + { + "name": "version true", + "source": "workos-python (new)", + "payload": { + "version": true, + "flags": {} + }, + "valid": false, + "note": "bool is a subclass of int in Python" + }, + { + "name": "version 2.0", + "source": "workos-python (new)", + "payload": { + "version": 2.0, + "flags": {} + }, + "valid": false, + "note": "2.0 == 2 in Python; .NET also rejects a float token", + "divergence": { + "node": "accepts (JSON 2.0 parses to 2)" + } + }, + { + "name": "a rule without an id", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a condition without an operator in a conditions rule", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a missing default_value is accepted", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "off_value": false, + "rules": [] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects" + } + }, + { + "name": "a missing off_value is accepted", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "rules": [] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects" + } + }, + { + "name": "conditions of an unknown kind are not validated", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "rollout", + "value": true, + "conditions": [ + { + "attribute": "plan" + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects" + } + }, + { + "name": "a conditions rule without conditions is accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true + } + ] + } + } + }, + "valid": true, + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "a one_of without a target type is accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "non-string one_of IDs are accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + 1 + ] + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "operands of an unknown operator are not validated", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "segment", + "target_type": { + "nested": true + } + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects a non-string target_type" + } + } + ] +} diff --git a/tests/feature_flags_runtime/helpers.py b/tests/feature_flags_runtime/helpers.py new file mode 100644 index 00000000..58e7ec78 --- /dev/null +++ b/tests/feature_flags_runtime/helpers.py @@ -0,0 +1,48 @@ +# @oagen-ignore-file +"""Helpers for the feature flags runtime client tests.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load_fixture(name: str) -> Dict[str, Any]: + return json.loads((FIXTURES / name).read_text()) + + +def payload(**flags: Dict[str, Any]) -> Dict[str, Any]: + return {"version": 2, "flags": flags} + + +def flag( + slug: str = "flag", + *, + enabled: bool = True, + default_value: Any = False, + off_value: Any = False, + rules: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + return { + "slug": slug, + "enabled": enabled, + "default_value": default_value, + "off_value": off_value, + "rules": rules or [], + } + + +def rule( + rule_id: str, value: Any, target_type: str, *ids: str, kind: str = "conditions" +) -> Dict[str, Any]: + return { + "id": rule_id, + "kind": kind, + "value": value, + "conditions": [ + {"target_type": target_type, "operator": "one_of", "values": list(ids)} + ], + } diff --git a/tests/feature_flags_runtime/test_configuration.py b/tests/feature_flags_runtime/test_configuration.py new file mode 100644 index 00000000..c5cc0eac --- /dev/null +++ b/tests/feature_flags_runtime/test_configuration.py @@ -0,0 +1,150 @@ +# @oagen-ignore-file +"""FeatureFlagsConfiguration: persistence and flag inspection.""" + +from __future__ import annotations + +import copy +import json +import pickle +from collections import UserDict +from types import MappingProxyType + +import pytest + +from workos.feature_flags.runtime._configuration import ( + ConditionDefinition, + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagDefinition, + RuleDefinition, +) + +from tests.feature_flags_runtime.helpers import flag, payload, rule + +PAYLOAD = { + **payload( + checkout=flag( + "checkout", + default_value=True, + rules=[ + rule("flag_rule_1", False, "organization", "org_2", "org_1"), + { + "id": "flag_rule_2", + "kind": "rollout", + "value": "variant-b", + "percentage": 10, + "conditions": [{"attribute": "plan", "values": ["pro", 3]}], + }, + { + "id": "flag_rule_3", + "kind": "conditions", + "value": True, + "conditions": [ + { + "operator": "segment", + "target_type": "user", + "values": ["seg"], + } + ], + }, + ], + ) + ), + "future_top_level": {"anything": [1, 2]}, +} + + +def test_round_trips_through_json_and_dict_with_unknown_fields() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + restored = FeatureFlagsConfiguration.from_json(configuration.to_json()) + + assert configuration.to_dict() == PAYLOAD + assert restored.to_dict() == PAYLOAD + assert json.loads(restored.to_json()) == PAYLOAD + + +def test_owns_a_copy_of_the_payload() -> None: + source = json.loads(json.dumps(PAYLOAD)) + configuration = FeatureFlagsConfiguration.from_dict(source) + source["flags"]["checkout"]["enabled"] = False + exported = configuration.to_dict() + exported["flags"].clear() + + assert configuration.get_flag("checkout").enabled is True # type: ignore[union-attr] + assert len(configuration) == 1 + + +def test_inspects_flags_as_received_including_rules_it_cannot_evaluate() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + + assert configuration.get_flag("checkout") == FlagDefinition( + slug="checkout", + enabled=True, + default_value=True, + off_value=False, + rules=( + RuleDefinition( + id="flag_rule_1", + kind="conditions", + value=False, + conditions=( + ConditionDefinition("organization", "one_of", ("org_2", "org_1")), + ), + ), + RuleDefinition( + id="flag_rule_2", + kind="rollout", + value=None, + conditions=(ConditionDefinition(None, None, ("pro",)),), + ), + RuleDefinition( + id="flag_rule_3", + kind="conditions", + value=True, + conditions=(ConditionDefinition("user", "segment", ("seg",)),), + ), + ), + ) + assert configuration.get_flag("missing") is None + assert configuration.slugs == ("checkout",) + assert "checkout" in configuration and 1 not in configuration + assert list(configuration) == ["checkout"] + + +def test_invalid_json_is_a_configuration_error() -> None: + with pytest.raises(FeatureFlagsConfigurationError, match="not valid JSON"): + FeatureFlagsConfiguration.from_json("{not json") + with pytest.raises(FeatureFlagsConfigurationError, match="(?i)expected an object"): + FeatureFlagsConfiguration.from_json("[]") + + +def test_configuration_errors_are_value_errors() -> None: + with pytest.raises(ValueError): + FeatureFlagsConfiguration.from_dict({"version": 1, "flags": {}}) + + +def test_values_that_are_not_json_are_rejected() -> None: + with pytest.raises(FeatureFlagsConfigurationError, match="JSON-serializable"): + FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {}, "when": object()} + ) + + +def test_accepts_any_mapping_type() -> None: + read_only = MappingProxyType({**PAYLOAD, "flags": UserDict(PAYLOAD["flags"])}) + configuration = FeatureFlagsConfiguration.from_dict(read_only) + + assert configuration.to_dict() == PAYLOAD + assert configuration.get_flag("checkout") is not None + + +def test_snapshots_pickle_and_copy() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + + restored = pickle.loads(pickle.dumps(configuration)) + assert restored is not configuration + assert restored.to_json() == configuration.to_json() + assert restored.get_flag("checkout") == configuration.get_flag("checkout") + # Immutable, so copies can share the instance. + assert copy.copy(configuration) is configuration + assert copy.deepcopy(configuration) is configuration diff --git a/tests/feature_flags_runtime/test_corpus.py b/tests/feature_flags_runtime/test_corpus.py new file mode 100644 index 00000000..db5e3df0 --- /dev/null +++ b/tests/feature_flags_runtime/test_corpus.py @@ -0,0 +1,115 @@ +# @oagen-ignore-file +"""Runs the JSON fixture corpus (shaped for the shared AUTH-7098 corpus).""" + +from __future__ import annotations + +import logging +from typing import Any, Dict + +import pytest + +from workos.feature_flags.runtime._configuration import ( + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + changed_flags, +) +from workos.feature_flags.runtime._context import ContextWarnings, normalize_context + +from tests.feature_flags_runtime.helpers import load_fixture + +EVALUATION = load_fixture("evaluation_cases.json") +PAYLOADS = load_fixture("payload_cases.json") +CHANGES = load_fixture("change_cases.json") +CONTEXTS = load_fixture("context_cases.json") + +_CASE_FIELDS = {"name", "source", "key", "flag", "evaluations", "malformed"} +_EVALUATION_FIELDS = {"context", "expect"} +_OPTIONAL_EVALUATION_FIELDS = {"name"} +_CHANGE_FIELDS = {"name", "source", "before", "after", "changed"} +_WARNINGS = ContextWarnings(logging.getLogger("tests.feature_flags_runtime.corpus")) + + +def _is_enabled( + configuration: FeatureFlagsConfiguration, + slug: str, + context: Dict[str, Any], + *, + default: bool, +) -> bool: + # `default` answers for a missing flag and for one without a boolean answer. + flag = configuration._compiled(slug) + if flag is None: + return default + answer = flag.evaluate(normalize_context(context, _WARNINGS)) + return default if answer is None else answer + + +def _observed( + configuration: FeatureFlagsConfiguration, slug: str, context: Dict[str, Any] +) -> Any: + with_false = _is_enabled(configuration, slug, context, default=False) + with_true = _is_enabled(configuration, slug, context, default=True) + if with_false == with_true: + return with_false + return "absent" if (with_false, with_true) == (False, True) else "inverted" + + +@pytest.mark.parametrize("case", EVALUATION["cases"], ids=lambda case: case["name"]) +def test_evaluation_corpus(case: Dict[str, Any]) -> None: + # An unrecognized field is a hard failure, never a silent skip. + assert set(case) <= _CASE_FIELDS, f"unknown case fields: {set(case) - _CASE_FIELDS}" + flag = case["flag"] + key = case.get("key", flag["slug"]) + configuration = FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {key: flag}} + ) + for evaluation in case["evaluations"]: + fields = set(evaluation) + assert ( + _EVALUATION_FIELDS + <= fields + <= _EVALUATION_FIELDS | _OPTIONAL_EVALUATION_FIELDS + ), f"unexpected evaluation fields: {fields}" + observed = _observed(configuration, key, evaluation["context"]) + assert observed == evaluation["expect"], evaluation + + +@pytest.mark.parametrize("case", PAYLOADS["cases"], ids=lambda case: case["name"]) +def test_payload_corpus(case: Dict[str, Any]) -> None: + if case["valid"]: + FeatureFlagsConfiguration(case["payload"]) + else: + with pytest.raises(FeatureFlagsConfigurationError): + FeatureFlagsConfiguration(case["payload"]) + + +@pytest.mark.parametrize("case", CHANGES["cases"], ids=lambda case: case["name"]) +def test_change_corpus(case: Dict[str, Any]) -> None: + assert set(case) <= _CHANGE_FIELDS, ( + f"unknown case fields: {set(case) - _CHANGE_FIELDS}" + ) + before = FeatureFlagsConfiguration(case.get("before", CHANGES["baseline"])) + after = FeatureFlagsConfiguration(case["after"]) + assert sorted(changed_flags(before, after)) == case["changed"] + + +@pytest.mark.parametrize("case", CONTEXTS["cases"], ids=lambda case: case["name"]) +def test_context_corpus(case: Dict[str, Any], caplog: pytest.LogCaptureFixture) -> None: + logger = logging.getLogger("tests.feature_flags_runtime.context") + with caplog.at_level(logging.WARNING, logger=logger.name): + targets = normalize_context(case["context"], ContextWarnings(logger)) + assert targets == case["targets"] + assert bool(caplog.records) is case["warns"] + + +def test_context_corpus_flag_matches_only_the_surviving_targets() -> None: + configuration = FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {"flag": CONTEXTS["flag"]}} + ) + for case in CONTEXTS["cases"]: + expected = ( + case["targets"].get("workspace") == "ws_1" + or case["targets"].get("user") == "user_1" + ) + observed = _is_enabled(configuration, "flag", case["context"], default=False) + assert observed is expected, case["name"]