mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
## Why Buzz server components need one typed feature-flag contract without forcing the open-source build to depend on LaunchDarkly. A staging or production relay must not be able to choose the wrong provider through runtime configuration. ## What - Adds typed boolean and signed `i64` flags with static, environment, and compile-gated LaunchDarkly evaluators. - Defines an executable relay artifact contract that compiles exactly one provider. - Rejects zero or multiple relay provider features at compile time. - Runs the LaunchDarkly feature tests and the provider compile matrix in the unit CI lane and fallback test script. ## How `buzz-feature-flags` stays provider-neutral. The future relay composition root will expose one build path per artifact feature: `static-feature-flags`, `environment-feature-flags`, or `launchdarkly-feature-flags`. Runtime configuration carries only settings for the selected provider. It never carries a provider name, fallback, or precedence rule. The crate still has no relay consumer. A locked compile fixture proves each singleton builds and every zero or multi-provider combination fails until the relay adopts this contract. This removes the proposed runtime `ProviderMode` and leaves one provider, one construction path, and one evaluator per artifact. ## Risk This PR still has no relay or app consumer, so it changes no user-visible behavior. Risk is limited to the new feature-flag crate, its compile contract, and its unit-test lane. ## Testing An external Rust consumer at `30cf968bfe85e82980fda7e63f5bebe649ca939f` exercised six environment-evaluator scenarios and 59 assertions. The compile-time correction adds no live consumer to exercise manually. ## Bigger picture When `buzz-relay` adopts this crate, its build manifests must map each distribution to one provider feature. That consumer PR must verify live Relay Proxy reachability, secret ingestion, lifecycle ownership, and user-visible behavior. Generated with Codex --------- Signed-off-by: tornquist <tornquist@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
954 lines
34 KiB
Rust
954 lines
34 KiB
Rust
use http::{uri::Authority, Uri};
|
|
use launchdarkly_server_sdk::{
|
|
BuildError, Client, ConfigBuildError, ConfigBuilder, Context, ContextBuilder,
|
|
MultiContextBuilder,
|
|
};
|
|
use std::time::Duration;
|
|
use thiserror::Error;
|
|
|
|
use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag};
|
|
|
|
const COMMUNITY_CONTEXT_KIND: &str = "community";
|
|
const PUBKEY_CONTEXT_KIND: &str = "pubkey";
|
|
|
|
/// Runtime inputs required to initialize a LaunchDarkly-backed evaluator.
|
|
#[derive(Clone)]
|
|
pub struct LaunchDarklyRuntimeConfig {
|
|
/// SDK key used to authenticate with LaunchDarkly.
|
|
pub sdk_key: String,
|
|
/// Optional Relay Proxy endpoint used for all LaunchDarkly service traffic.
|
|
pub relay_proxy_endpoint: Option<String>,
|
|
}
|
|
|
|
impl std::fmt::Debug for LaunchDarklyRuntimeConfig {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("LaunchDarklyRuntimeConfig")
|
|
.field("sdk_key", &"[REDACTED]")
|
|
.field("relay_proxy_endpoint", &self.relay_proxy_endpoint)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl LaunchDarklyRuntimeConfig {
|
|
/// Create a runtime config from explicit caller-provided values.
|
|
pub fn new(sdk_key: impl Into<String>) -> Self {
|
|
Self {
|
|
sdk_key: sdk_key.into(),
|
|
relay_proxy_endpoint: None,
|
|
}
|
|
}
|
|
|
|
/// Set the optional Relay Proxy endpoint.
|
|
pub fn with_relay_proxy_endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
|
self.relay_proxy_endpoint = Some(endpoint.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Errors building a LaunchDarkly evaluator from runtime inputs.
|
|
#[derive(Debug, Error)]
|
|
pub enum LaunchDarklyInitError {
|
|
/// Caller provided an empty SDK key.
|
|
#[error("launchdarkly sdk key must not be empty")]
|
|
EmptySdkKey,
|
|
/// Caller provided an empty relay-proxy endpoint.
|
|
#[error("launchdarkly relay proxy endpoint must not be empty")]
|
|
EmptyRelayProxyEndpoint,
|
|
/// Caller provided an invalid relay-proxy endpoint.
|
|
#[error("launchdarkly relay proxy endpoint must be an absolute http(s) URI with authority")]
|
|
InvalidRelayProxyEndpoint(InvalidRelayProxyEndpointReason),
|
|
/// LaunchDarkly SDK config build failed.
|
|
#[error("launchdarkly config build failed: {0}")]
|
|
ConfigBuild(#[from] ConfigBuildError),
|
|
/// LaunchDarkly SDK client build failed.
|
|
#[error("launchdarkly client build failed: {0}")]
|
|
ClientBuild(#[from] BuildError),
|
|
}
|
|
|
|
/// Why a relay-proxy endpoint failed pre-validation.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum InvalidRelayProxyEndpointReason {
|
|
/// Endpoint could not be parsed as a URI.
|
|
MalformedUri,
|
|
/// Endpoint scheme is not `http` or `https`.
|
|
InvalidScheme,
|
|
/// Endpoint has no authority section (`host[:port]`).
|
|
MissingAuthority,
|
|
}
|
|
|
|
/// Errors starting a LaunchDarkly evaluator and waiting for initialization.
|
|
#[derive(Debug, Error)]
|
|
pub enum LaunchDarklyStartError {
|
|
/// LaunchDarkly client did not initialize before timeout.
|
|
#[error("launchdarkly initialization timed out after {0:?}")]
|
|
InitializationTimeout(Duration),
|
|
/// LaunchDarkly client reported failed initialization.
|
|
#[error("launchdarkly initialization failed")]
|
|
InitializationFailed,
|
|
}
|
|
|
|
/// Feature-flag evaluator backed by the LaunchDarkly Rust server SDK.
|
|
pub struct LaunchDarklyEvaluator {
|
|
client: Client,
|
|
}
|
|
|
|
impl std::fmt::Debug for LaunchDarklyEvaluator {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("LaunchDarklyEvaluator").finish()
|
|
}
|
|
}
|
|
|
|
impl LaunchDarklyEvaluator {
|
|
/// Wrap an already-constructed LaunchDarkly client.
|
|
pub(crate) fn new(client: Client) -> Self {
|
|
Self { client }
|
|
}
|
|
|
|
/// Build a LaunchDarkly client from explicit runtime configuration.
|
|
pub fn from_runtime_config(
|
|
config: LaunchDarklyRuntimeConfig,
|
|
) -> Result<Self, LaunchDarklyInitError> {
|
|
let sdk_key = config.sdk_key.trim();
|
|
if sdk_key.is_empty() {
|
|
return Err(LaunchDarklyInitError::EmptySdkKey);
|
|
}
|
|
|
|
let mut builder = ConfigBuilder::new(sdk_key);
|
|
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
|
|
if relay_proxy_endpoint.trim().is_empty() {
|
|
return Err(LaunchDarklyInitError::EmptyRelayProxyEndpoint);
|
|
}
|
|
let relay_proxy_endpoint = validate_relay_proxy_endpoint(&relay_proxy_endpoint)?;
|
|
let mut service_endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new();
|
|
service_endpoints.relay_proxy(&relay_proxy_endpoint);
|
|
builder = builder.service_endpoints(&service_endpoints);
|
|
}
|
|
|
|
let config = builder.build()?;
|
|
let client = Client::build(config)?;
|
|
Ok(Self::new(client))
|
|
}
|
|
|
|
/// Start background tasks and wait for SDK initialization.
|
|
///
|
|
/// This method must run inside a Tokio runtime.
|
|
pub async fn start_with_default_executor_and_wait(
|
|
&self,
|
|
timeout: Duration,
|
|
) -> Result<(), LaunchDarklyStartError> {
|
|
self.client.start_with_default_executor();
|
|
|
|
match self.client.wait_for_initialization(timeout).await {
|
|
Some(true) => Ok(()),
|
|
Some(false) => Err(LaunchDarklyStartError::InitializationFailed),
|
|
None => Err(LaunchDarklyStartError::InitializationTimeout(timeout)),
|
|
}
|
|
}
|
|
|
|
/// Gracefully stop background tasks and flush pending analytics events.
|
|
///
|
|
/// This call blocks the current thread until pending analytics delivery
|
|
/// completes. Async callers should run it in a blocking shutdown path.
|
|
pub fn close(&self) {
|
|
self.client.close();
|
|
}
|
|
}
|
|
|
|
impl FlagEvaluator for LaunchDarklyEvaluator {
|
|
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool {
|
|
let context = match launchdarkly_context(context) {
|
|
Ok(context) => context,
|
|
Err(_) => return flag.default(),
|
|
};
|
|
self.client
|
|
.bool_variation(&context, flag.key(), flag.default())
|
|
}
|
|
|
|
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
|
|
let context = match launchdarkly_context(context) {
|
|
Ok(context) => context,
|
|
Err(_) => return flag.default(),
|
|
};
|
|
|
|
let detail =
|
|
self.client
|
|
.float_variation_detail(&context, flag.key(), flag.default() as f64);
|
|
|
|
if detail.variation_index.is_none() {
|
|
return flag.default();
|
|
}
|
|
|
|
let Some(value) = detail.value else {
|
|
return flag.default();
|
|
};
|
|
|
|
strict_f64_to_i64(value).unwrap_or_else(|| flag.default())
|
|
}
|
|
}
|
|
|
|
fn strict_f64_to_i64(value: f64) -> Option<i64> {
|
|
if !value.is_finite() || value.fract() != 0.0 {
|
|
return None;
|
|
}
|
|
|
|
i64::try_from(value as i128).ok()
|
|
}
|
|
|
|
fn validate_relay_proxy_endpoint(endpoint: &str) -> Result<String, LaunchDarklyInitError> {
|
|
if endpoint.trim() != endpoint {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MalformedUri,
|
|
));
|
|
}
|
|
|
|
if endpoint.contains('#') {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MalformedUri,
|
|
));
|
|
}
|
|
|
|
let parsed = endpoint.parse::<Uri>().map_err(|_| {
|
|
LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MalformedUri,
|
|
)
|
|
})?;
|
|
|
|
if !matches!(parsed.scheme_str(), Some("http" | "https")) {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::InvalidScheme,
|
|
));
|
|
}
|
|
|
|
if parsed.query().is_some() {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MalformedUri,
|
|
));
|
|
}
|
|
|
|
let Some(authority) = parsed.authority() else {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MissingAuthority,
|
|
));
|
|
};
|
|
|
|
if authority.host().is_empty() {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MissingAuthority,
|
|
));
|
|
}
|
|
|
|
if authority_has_explicit_port(authority) && authority.port_u16().is_none() {
|
|
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::MalformedUri,
|
|
));
|
|
}
|
|
|
|
Ok(endpoint.to_owned())
|
|
}
|
|
|
|
fn authority_has_explicit_port(authority: &Authority) -> bool {
|
|
let authority = authority
|
|
.as_str()
|
|
.rsplit_once('@')
|
|
.map_or_else(|| authority.as_str(), |(_, host_and_port)| host_and_port);
|
|
|
|
if authority.starts_with('[') {
|
|
return authority.contains("]:");
|
|
}
|
|
|
|
authority
|
|
.rsplit_once(':')
|
|
.map(|(_, port)| !port.is_empty())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Build the LaunchDarkly context used for evaluation.
|
|
///
|
|
/// The adapter always includes a `community` kind context and optionally adds a
|
|
/// global `pubkey` kind context when an actor is present.
|
|
fn launchdarkly_context(context: &EvaluationContext) -> Result<Context, String> {
|
|
let mut community = ContextBuilder::new(context.community().to_string());
|
|
community.kind(COMMUNITY_CONTEXT_KIND);
|
|
let community = community.build()?;
|
|
|
|
let Some(actor_pubkey) = context.actor_pubkey() else {
|
|
return Ok(community);
|
|
};
|
|
|
|
let mut actor = ContextBuilder::new(actor_pubkey.to_hex());
|
|
actor.kind(PUBKEY_CONTEXT_KIND);
|
|
let actor = actor.build()?;
|
|
|
|
MultiContextBuilder::of(vec![community, actor]).build()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use buzz_core::{CommunityId, PublicKey};
|
|
use launchdarkly_server_sdk::{
|
|
Config, Flag, FlagBuilder, FlagValue, Kind, NullEventProcessorBuilder, TestData,
|
|
};
|
|
|
|
fn community(id: &str) -> CommunityId {
|
|
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
|
|
}
|
|
|
|
fn actor() -> PublicKey {
|
|
PublicKey::from_hex("5581946f95a03e6afb43027ec89b21507f040d35fd6f8594f168f4298f96f9cb")
|
|
.expect("valid pubkey")
|
|
}
|
|
|
|
fn kind(name: &'static str) -> Kind {
|
|
Kind::try_from(name).expect("valid LaunchDarkly context kind")
|
|
}
|
|
|
|
fn weighted_rollout_flag(key: &str, context_kind: Option<&str>) -> Flag {
|
|
let context_kind_json = context_kind.map_or_else(String::new, |context_kind| {
|
|
format!(",\"contextKind\":\"{context_kind}\"")
|
|
});
|
|
|
|
serde_json::from_str(&format!(
|
|
r#"{{
|
|
"key": "{key}",
|
|
"version": 1,
|
|
"on": true,
|
|
"targets": [],
|
|
"rules": [],
|
|
"prerequisites": [],
|
|
"fallthrough": {{
|
|
"rollout": {{
|
|
"variations": [
|
|
{{ "variation": 0, "weight": 1 }},
|
|
{{ "variation": 1, "weight": 99999 }}
|
|
]
|
|
{context_kind_json}
|
|
}}
|
|
}},
|
|
"offVariation": 0,
|
|
"variations": [false, true],
|
|
"clientSideAvailability": {{
|
|
"usingMobileKey": false,
|
|
"usingEnvironmentId": false
|
|
}},
|
|
"salt": "saltyA"
|
|
}}"#
|
|
))
|
|
.expect("valid preconfigured weighted rollout flag")
|
|
}
|
|
|
|
fn test_config(test_data: &TestData) -> Config {
|
|
ConfigBuilder::new("sdk-key")
|
|
.data_source(test_data)
|
|
.event_processor(&NullEventProcessorBuilder::new())
|
|
.build()
|
|
.expect("launchdarkly test config")
|
|
}
|
|
|
|
fn default_test_config(test_data: &TestData) -> Config {
|
|
ConfigBuilder::new("sdk-key")
|
|
.data_source(test_data)
|
|
.build()
|
|
.expect("launchdarkly default test config")
|
|
}
|
|
|
|
async fn started_evaluator(test_data: &TestData) -> LaunchDarklyEvaluator {
|
|
let config = test_config(test_data);
|
|
let client = Client::build(config).expect("launchdarkly test client");
|
|
let evaluator = LaunchDarklyEvaluator::new(client);
|
|
evaluator
|
|
.start_with_default_executor_and_wait(Duration::from_secs(1))
|
|
.await
|
|
.expect("launchdarkly initialized");
|
|
evaluator
|
|
}
|
|
|
|
#[test]
|
|
fn launchdarkly_test_config_uses_null_event_processor() {
|
|
let test_data = TestData::new();
|
|
let config = test_config(&test_data);
|
|
let default_config = default_test_config(&test_data);
|
|
let endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new()
|
|
.build()
|
|
.expect("default service endpoints");
|
|
let event_processor = config
|
|
.event_processor_builder()
|
|
.build(&endpoints, config.sdk_key(), None)
|
|
.expect("event processor build");
|
|
let default_event_processor = default_config
|
|
.event_processor_builder()
|
|
.build(&endpoints, default_config.sdk_key(), None)
|
|
.expect("default event processor build");
|
|
|
|
event_processor.close();
|
|
default_event_processor.close();
|
|
assert!(
|
|
event_processor.flush_blocking(Duration::from_millis(10)),
|
|
"null event processor should remain a no-op after close"
|
|
);
|
|
assert!(
|
|
!default_event_processor.flush_blocking(Duration::from_millis(10)),
|
|
"default event processor should fail flush_blocking after close"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_config_debug_redacts_sdk_key() {
|
|
let config = LaunchDarklyRuntimeConfig::new("super-secret-sdk-key")
|
|
.with_relay_proxy_endpoint("https://relay.internal");
|
|
let debug = format!("{config:?}");
|
|
|
|
assert!(!debug.contains("super-secret-sdk-key"));
|
|
assert!(debug.contains("[REDACTED]"));
|
|
assert!(debug.contains("https://relay.internal"));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_relay_proxy_endpoint_returns_typed_error_without_panic() {
|
|
let result = std::panic::catch_unwind(|| {
|
|
LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key")
|
|
.with_relay_proxy_endpoint("not a valid endpoint"),
|
|
)
|
|
});
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"invalid endpoint should return Err, not panic"
|
|
);
|
|
assert!(matches!(
|
|
result.expect("catch_unwind result"),
|
|
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn relay_proxy_endpoint_rejects_non_http_scheme_and_missing_authority() {
|
|
let invalid_scheme = LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key")
|
|
.with_relay_proxy_endpoint("ftp://relay.internal:8030"),
|
|
);
|
|
assert!(matches!(
|
|
invalid_scheme,
|
|
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
|
InvalidRelayProxyEndpointReason::InvalidScheme
|
|
))
|
|
));
|
|
|
|
for endpoint in [
|
|
"https://",
|
|
"http:///missing-authority",
|
|
"https:///still-missing-authority",
|
|
"https://:8030/path-prefix",
|
|
] {
|
|
let result = LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
|
);
|
|
assert!(matches!(
|
|
result,
|
|
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn relay_proxy_endpoint_rejects_query_fragment_and_invalid_ports() {
|
|
let invalid_cases = [
|
|
"https://relay.internal/path?env=staging",
|
|
"https://relay.internal/path#fragment",
|
|
"https://relay.internal:notaport/path-prefix",
|
|
"https://relay.internal:99999/path-prefix",
|
|
];
|
|
|
|
for endpoint in invalid_cases {
|
|
let error = LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
|
)
|
|
.expect_err("endpoint should be rejected");
|
|
|
|
assert!(matches!(
|
|
error,
|
|
LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)
|
|
));
|
|
assert!(!format!("{error}").contains(endpoint));
|
|
assert!(!format!("{error:?}").contains(endpoint));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn relay_proxy_endpoint_rejects_leading_or_trailing_whitespace() {
|
|
for endpoint in [
|
|
" https://relay.internal:8030",
|
|
"https://relay.internal:8030 ",
|
|
"\thttps://relay.internal:8030",
|
|
"https://relay.internal:8030\n",
|
|
] {
|
|
let result = LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
|
);
|
|
assert!(matches!(
|
|
result,
|
|
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn relay_proxy_endpoint_accepts_absolute_http_and_https_uris() {
|
|
let valid_cases = [
|
|
"http://relay.internal:8030",
|
|
"https://relay.internal",
|
|
"https://relay.internal:8443/path-prefix",
|
|
];
|
|
|
|
for endpoint in valid_cases {
|
|
let result = LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
|
);
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"expected valid endpoint {endpoint} to be accepted"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn relay_proxy_endpoint_error_does_not_leak_raw_endpoint_value() {
|
|
let endpoint = "https://bad endpoint with spaces";
|
|
let error = LaunchDarklyEvaluator::from_runtime_config(
|
|
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
|
)
|
|
.expect_err("invalid endpoint should fail");
|
|
|
|
assert!(!format!("{error}").contains(endpoint));
|
|
assert!(!format!("{error:?}").contains(endpoint));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn community_only_context_targets_named_community_kind() {
|
|
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("community-only")
|
|
.fallthrough_variation(false)
|
|
.variation_for_key(kind("community"), community.to_string(), true),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
|
|
assert!(evaluator.evaluate_bool(
|
|
BooleanFlag::new("community-only", false),
|
|
&EvaluationContext::for_community(community),
|
|
));
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn actor_context_targets_community_and_pubkey_kinds() {
|
|
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let actor = actor();
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("by-community")
|
|
.fallthrough_variation(false)
|
|
.variation_for_key(kind("community"), community.to_string(), true),
|
|
);
|
|
test_data.update(
|
|
FlagBuilder::new("by-pubkey")
|
|
.fallthrough_variation(false)
|
|
.variation_for_key(kind("pubkey"), actor.to_hex(), true),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(community, actor);
|
|
|
|
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-community", false), &context));
|
|
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-pubkey", false), &context));
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_actor_can_receive_different_variations_by_community() {
|
|
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
|
let actor = actor();
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("community-rollout")
|
|
.fallthrough_variation(false)
|
|
.variation_for_key(kind("community"), community_a.to_string(), true),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let flag = BooleanFlag::new("community-rollout", false);
|
|
|
|
assert!(evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_a, actor),));
|
|
assert!(!evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_b, actor),));
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn community_kind_weighted_rollout_uses_community_key_bucketing() {
|
|
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let test_data = TestData::new();
|
|
test_data.use_preconfigured_flag(weighted_rollout_flag(
|
|
"community-weighted-rollout",
|
|
Some("community"),
|
|
));
|
|
test_data
|
|
.use_preconfigured_flag(weighted_rollout_flag("missing-context-kind-rollout", None));
|
|
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_community(community);
|
|
|
|
assert!(evaluator.evaluate_bool(
|
|
BooleanFlag::new("community-weighted-rollout", false),
|
|
&context,
|
|
));
|
|
assert!(!evaluator.evaluate_bool(
|
|
BooleanFlag::new("missing-context-kind-rollout", true),
|
|
&context,
|
|
));
|
|
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_flag_falls_back_to_declared_default() {
|
|
let test_data = TestData::new();
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context =
|
|
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
|
|
|
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.missing", true), &context,));
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_type_falls_back_to_declared_default() {
|
|
let test_data = TestData::new();
|
|
test_data.update(FlagBuilder::new("relay.feature.bool").variation_for_all(true));
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.string")
|
|
.value_for_all(FlagValue::Str("red".to_owned())),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(
|
|
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
|
actor(),
|
|
);
|
|
|
|
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.bool", false), &context,));
|
|
assert!(
|
|
!evaluator.evaluate_bool(BooleanFlag::new("relay.feature.string", false), &context,)
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn community_only_context_targets_integer_variation() {
|
|
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("community-int")
|
|
.variations([FlagValue::from(0_i64), FlagValue::from(13_i64)])
|
|
.fallthrough_variation_index(0)
|
|
.variation_index_for_key(kind("community"), community.to_string(), 1),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("community-int", -1),
|
|
&EvaluationContext::for_community(community),
|
|
),
|
|
13
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn actor_context_targets_integer_variation_by_community_and_pubkey() {
|
|
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let actor = actor();
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("by-community-int")
|
|
.variations([FlagValue::from(0_i64), FlagValue::from(8_i64)])
|
|
.fallthrough_variation_index(0)
|
|
.variation_index_for_key(kind("community"), community.to_string(), 1),
|
|
);
|
|
test_data.update(
|
|
FlagBuilder::new("by-pubkey-int")
|
|
.variations([FlagValue::from(0_i64), FlagValue::from(21_i64)])
|
|
.fallthrough_variation_index(0)
|
|
.variation_index_for_key(kind("pubkey"), actor.to_hex(), 1),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(community, actor);
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(IntegerFlag::new("by-community-int", -1), &context),
|
|
8
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(IntegerFlag::new("by-pubkey-int", -1), &context),
|
|
21
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_actor_can_receive_different_integer_variations_by_community() {
|
|
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
|
let actor = actor();
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("community-int-rollout")
|
|
.variations([FlagValue::from(-11_i64), FlagValue::from(99_i64)])
|
|
.fallthrough_variation_index(0)
|
|
.variation_index_for_key(kind("community"), community_a.to_string(), 1),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("community-int-rollout", -1),
|
|
&EvaluationContext::for_actor(community_a, actor),
|
|
),
|
|
99
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("community-int-rollout", -1),
|
|
&EvaluationContext::for_actor(community_b, actor),
|
|
),
|
|
-11
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_integer_flag_falls_back_to_declared_default() {
|
|
let test_data = TestData::new();
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context =
|
|
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(IntegerFlag::new("relay.feature.missing-int", -7), &context),
|
|
-7
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_integer_flag_preserves_exact_declared_default_bytes() {
|
|
let test_data = TestData::new();
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context =
|
|
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
|
|
|
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.missing-int-precise-default", default),
|
|
&context,
|
|
),
|
|
default,
|
|
"missing-flag fallback must preserve exact declared default"
|
|
);
|
|
}
|
|
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_type_integer_falls_back_to_declared_default() {
|
|
let test_data = TestData::new();
|
|
test_data
|
|
.update(FlagBuilder::new("relay.feature.int").value_for_all(FlagValue::from(17_i64)));
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-bool").value_for_all(FlagValue::from(true)),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(
|
|
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
|
actor(),
|
|
);
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int", 0), &context),
|
|
17
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int-bool", -5), &context),
|
|
-5
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_type_integer_flag_preserves_exact_declared_default_bytes() {
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-wrong-type-for-precise-default")
|
|
.value_for_all(FlagValue::from(true)),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(
|
|
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
|
actor(),
|
|
);
|
|
|
|
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-wrong-type-for-precise-default", default),
|
|
&context,
|
|
),
|
|
default,
|
|
"wrong-type fallback must preserve exact declared default"
|
|
);
|
|
}
|
|
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_not_ready_preserves_exact_declared_default_bytes() {
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-provider-not-ready")
|
|
.value_for_all(FlagValue::from(123_i64)),
|
|
);
|
|
|
|
let config = test_config(&test_data);
|
|
let client = Client::build(config).expect("launchdarkly test client");
|
|
let evaluator = LaunchDarklyEvaluator::new(client);
|
|
let context = EvaluationContext::for_actor(
|
|
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
|
actor(),
|
|
);
|
|
|
|
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-provider-not-ready", default),
|
|
&context,
|
|
),
|
|
default,
|
|
"provider-not-ready fallback must preserve exact declared default"
|
|
);
|
|
}
|
|
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fractional_integer_variation_falls_back_to_declared_default() {
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-fractional-positive")
|
|
.value_for_all(FlagValue::from(17.75_f64)),
|
|
);
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-fractional-negative")
|
|
.value_for_all(FlagValue::from(-17.75_f64)),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(
|
|
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
|
actor(),
|
|
);
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-fractional-positive", 123),
|
|
&context,
|
|
),
|
|
123
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-fractional-negative", -456),
|
|
&context,
|
|
),
|
|
-456
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn integer_variation_accepts_exact_large_f64_backed_i64_values() {
|
|
let test_data = TestData::new();
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-large-positive")
|
|
.value_for_all(FlagValue::from(1_152_921_504_606_846_976_i64)),
|
|
);
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-large-negative")
|
|
.value_for_all(FlagValue::from(-1_152_921_504_606_846_976_i64)),
|
|
);
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-min-boundary")
|
|
.value_for_all(FlagValue::from(i64::MIN)),
|
|
);
|
|
test_data.update(
|
|
FlagBuilder::new("relay.feature.int-max-rounds-out-of-range")
|
|
.value_for_all(FlagValue::from(i64::MAX)),
|
|
);
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
let context = EvaluationContext::for_actor(
|
|
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
|
actor(),
|
|
);
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-large-positive", 0),
|
|
&context,
|
|
),
|
|
1_152_921_504_606_846_976_i64
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-large-negative", 0),
|
|
&context,
|
|
),
|
|
-1_152_921_504_606_846_976_i64
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-min-boundary", 11),
|
|
&context,
|
|
),
|
|
i64::MIN
|
|
);
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("relay.feature.int-max-rounds-out-of-range", -12),
|
|
&context,
|
|
),
|
|
-12
|
|
);
|
|
evaluator.close();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn negative_integer_variation_is_supported() {
|
|
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
|
let test_data = TestData::new();
|
|
test_data.update(FlagBuilder::new("negative-int").value_for_all(FlagValue::from(-42_i64)));
|
|
let evaluator = started_evaluator(&test_data).await;
|
|
|
|
assert_eq!(
|
|
evaluator.evaluate_int(
|
|
IntegerFlag::new("negative-int", 3),
|
|
&EvaluationContext::for_community(community),
|
|
),
|
|
-42
|
|
);
|
|
evaluator.close();
|
|
}
|
|
}
|