diff --git a/packages/common_client/lib/src/data_sources/fdv2/endpoints.dart b/packages/common_client/lib/src/data_sources/fdv2/endpoints.dart new file mode 100644 index 0000000..8b50bda --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/endpoints.dart @@ -0,0 +1,22 @@ +/// FDv2 endpoint paths. +/// +/// These paths are uniform across mobile and browser SDKs; FDv2 does +/// not distinguish between platforms at the endpoint level. +abstract final class FDv2Endpoints { + /// Polling path. Used as-is for POST requests (context sent in the + /// request body) and as the prefix for GET requests via [pollingGet]. + static const String polling = '/sdk/poll/eval'; + + /// Streaming path. Used as-is for POST requests (context sent in the + /// request body) and as the prefix for GET requests via [streamingGet]. + static const String streaming = '/sdk/stream/eval'; + + /// Builds the polling GET path with the base64url-encoded context + /// embedded in the URL path. + static String pollingGet(String encodedContext) => '$polling/$encodedContext'; + + /// Builds the streaming GET path with the base64url-encoded context + /// embedded in the URL path. + static String streamingGet(String encodedContext) => + '$streaming/$encodedContext'; +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/polling_base.dart b/packages/common_client/lib/src/data_sources/fdv2/polling_base.dart new file mode 100644 index 0000000..2e4ebe8 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/polling_base.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; + +import 'flag_eval_mapper.dart'; +import 'payload.dart'; +import 'protocol_handler.dart'; +import 'protocol_types.dart'; +import 'requestor.dart'; +import 'selector.dart'; +import 'source_result.dart'; + +/// Performs a single FDv2 poll and translates the response into an +/// [FDv2SourceResult]. +/// +/// Wraps an [FDv2Requestor] with FDv2 protocol semantics: +/// +/// - Network errors --> [SourceState.interrupted] with a sanitized +/// message. +/// - `x-ld-fd-fallback: true` header --> terminal error with +/// `fdv1Fallback: true`. This check takes precedence over the body +/// and over the status code: if the server signals fallback, the +/// SDK switches to FDv1 regardless of whether a `200`, `304`, or +/// error response carries the header. +/// - HTTP `304 Not Modified` --> an empty change set with +/// [PayloadType.none], confirming the cached data is current. +/// - Other 4xx/5xx --> interrupted (recoverable) or terminalError +/// (non-recoverable) based on [isHttpGloballyRecoverable]. +/// - `200` --> body is parsed as an [FDv2EventsCollection] and fed +/// through an [FDv2ProtocolHandler]. The first emitted action +/// determines the result. +final class FDv2PollingBase { + final LDLogger _logger; + final FDv2Requestor _requestor; + final DateTime Function() _now; + + FDv2PollingBase({ + required LDLogger logger, + required FDv2Requestor requestor, + DateTime Function()? now, + }) : _logger = logger.subLogger('FDv2PollingBase'), + _requestor = requestor, + _now = now ?? DateTime.now; + + /// Performs a single poll. Never throws; all failures, including + /// malformed response bodies, are reported as [StatusResult]s. + Future pollOnce({Selector basis = Selector.empty}) async { + final RequestorResponse response; + try { + response = await _requestor.request(basis: basis); + } catch (err) { + // Log only the sanitized form. The raw exception's `toString()` can + // embed PII (e.g. `http.ClientException` formats as + // `'ClientException: , uri='`, and the URL contains + // the base64url-encoded context in GET mode). + final sanitized = _describeError(err); + _logger.warn('Polling request failed: $sanitized'); + return FDv2SourceResults.interrupted(message: sanitized); + } + return _processResponse(response); + } + + FDv2SourceResult _processResponse(RequestorResponse response) { + // Match `x-ld-fd-fallback` case-insensitively. Servers shouldn't send + // mixed case, but it costs nothing to be lenient on input. + final fdv1Fallback = + response.headers['x-ld-fd-fallback']?.toLowerCase() == 'true'; + final environmentId = response.headers['x-ld-envid']; + + if (fdv1Fallback) { + return FDv2SourceResults.terminalError( + statusCode: response.status, + message: 'Server requested FDv1 fallback', + fdv1Fallback: true, + ); + } + + // 304 Not Modified means the SDK's cached data is confirmed current. + if (response.status == 304) { + return ChangeSetResult( + payload: const Payload(type: PayloadType.none, updates: []), + environmentId: environmentId, + freshness: _now(), + persist: true, + ); + } + + if (response.status >= 400) { + final message = 'Received unexpected status code: ${response.status}'; + if (isHttpGloballyRecoverable(response.status)) { + _logger.warn('$message; will retry'); + return FDv2SourceResults.interrupted( + statusCode: response.status, + message: message, + ); + } + _logger.error('$message; will not retry'); + return FDv2SourceResults.terminalError( + statusCode: response.status, + message: message, + ); + } + + return _parseBody(response, environmentId: environmentId); + } + + FDv2SourceResult _parseBody( + RequestorResponse response, { + String? environmentId, + }) { + // The whole parse path is wrapped: jsonDecode plus the structural + // casts inside FDv2EventsCollection.fromJson and the per-event + // PutObjectEvent/DeleteObjectEvent/PayloadIntent/etc. fromJson calls + // can all throw on shapes the protocol types don't accept. + try { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return FDv2SourceResults.interrupted( + statusCode: response.status, + message: 'Polling response was not a JSON object', + ); + } + + final collection = FDv2EventsCollection.fromJson(decoded); + final handler = FDv2ProtocolHandler( + objProcessors: {flagEvalKind: processFlagEval}, + logger: _logger, + ); + + for (final event in collection.events) { + final action = handler.processEvent(event); + switch (action) { + case ActionPayload(:final payload): + return ChangeSetResult( + payload: payload, + environmentId: environmentId, + freshness: _now(), + persist: true, + ); + case ActionGoodbye(:final reason): + return FDv2SourceResults.goodbyeResult(message: reason); + case ActionServerError(:final reason): + return FDv2SourceResults.interrupted(message: reason); + case ActionError(:final message): + return FDv2SourceResults.interrupted(message: message); + case ActionNone(): + // Continue accumulating events until a payload-transferred or + // terminal action is reached. + break; + } + } + + // The response had no payload-transferred event. The protocol + // handler is left in a partial state with nothing to emit, which + // is a protocol violation for a polling response. + return FDv2SourceResults.interrupted( + statusCode: response.status, + message: 'Polling response did not include a complete payload', + ); + } catch (err, stack) { + // Log only the type at error level (not the message โ€” `jsonDecode` + // includes a slice of the offending body, which is server-supplied). + // The full detail goes to debug, where it is gated by the user's + // log level. + _logger.error('Failed to parse polling response (${err.runtimeType})'); + _logger.debug('Polling response parse failure detail: $err\n$stack'); + return FDv2SourceResults.interrupted( + statusCode: response.status, + message: 'Polling response body was malformed', + ); + } + } + + /// Categorizes an exception thrown by the requestor into a fixed, + /// sanitized message. The raw exception's string form (which can carry + /// remote address, certificate detail, OS error strings, or โ€” in the + /// case of `http.ClientException` โ€” the full request URL) is never + /// echoed to the public status surface or to the warn log. + /// + /// Type checks via `is` are minification-safe (unlike substring + /// matches against `runtimeType.toString()`). + String _describeError(Object err) { + if (err is TimeoutException) { + return 'Polling request timed out'; + } + if (err is http.ClientException) { + return 'Network error during polling request'; + } + // dart:io's TlsException / HandshakeException can't be caught by `is` + // here without making this file io-only, so fall back to the type + // name. This is a best-effort label; if minification mangles the + // type name we land in the default branch below, which is still safe. + final type = err.runtimeType.toString(); + if (type.contains('Tls') || type.contains('Handshake')) { + return 'TLS error during polling request'; + } + return 'Polling request failed'; + } +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/requestor.dart b/packages/common_client/lib/src/data_sources/fdv2/requestor.dart new file mode 100644 index 0000000..1412cec --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/requestor.dart @@ -0,0 +1,140 @@ +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; + +import 'endpoints.dart'; +import 'selector.dart'; + +typedef HttpClientFactory = HttpClient Function(HttpProperties httpProperties); + +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { + return HttpClient(httpProperties: httpProperties); +} + +/// The shape of a completed HTTP response from the FDv2 polling endpoint. +typedef RequestorResponse = ({ + int status, + Map headers, + String body, +}); + +/// Issues a single HTTP poll against the FDv2 polling endpoint. +/// +/// Pure HTTP layer: builds the URL, sends the request, tracks `ETag` +/// across calls on the same instance, and returns the raw response. It +/// does no FDv2 protocol parsing or error classification -- that is the +/// responsibility of the caller (see [FDv2PollingBase]). +/// +/// One [FDv2Requestor] is bound to a single evaluation context. Switching +/// contexts requires a fresh instance so a previous context's `ETag` +/// can never leak into a request for a different context. +/// +/// Calls to [request] are not safe to interleave on a single instance -- +/// `ETag` tracking assumes serial requests. Callers (the polling +/// synchronizer) must wait for each [request] to complete before issuing +/// the next. +final class FDv2Requestor { + final LDLogger _logger; + final HttpClient _client; + final Uri _baseUri; + final String _contextEncoded; + final String _contextJson; + final bool _usePost; + final bool _withReasons; + String? _lastEtag; + + FDv2Requestor({ + required LDLogger logger, + required ServiceEndpoints endpoints, + required String contextEncoded, + required String contextJson, + required bool usePost, + required bool withReasons, + required HttpProperties httpProperties, + HttpClientFactory httpClientFactory = _defaultHttpClientFactory, + }) : _logger = logger.subLogger('FDv2Requestor'), + _baseUri = Uri.parse(endpoints.polling), + _contextEncoded = contextEncoded, + _contextJson = contextJson, + _usePost = usePost, + _withReasons = withReasons, + _client = httpClientFactory(usePost + ? httpProperties.withHeaders({'content-type': 'application/json'}) + : httpProperties); + + /// Sends a single poll request, optionally including a [basis] selector + /// for delta updates. Throws on network errors; otherwise returns the + /// response. Tracks `ETag` across successful (`200`) responses on this + /// instance. + Future request({Selector basis = Selector.empty}) async { + final uri = _buildUri(basis: basis); + final method = _usePost ? RequestMethod.post : RequestMethod.get; + final additionalHeaders = {}; + if (_lastEtag != null) { + additionalHeaders['if-none-match'] = _lastEtag!; + } + + // Avoid logging the full URI -- in GET mode it embeds the + // base64url-encoded context, which is reversible PII. + _logger.debug('FDv2 poll: method=$method, hasEtag=${_lastEtag != null}, ' + 'hasBasis=${basis.isNotEmpty}'); + + final response = await _client.request( + method, + uri, + additionalHeaders: additionalHeaders.isEmpty ? null : additionalHeaders, + body: _usePost ? _contextJson : null, + ); + + // Only persist the ETag from a successful response. Non-200 responses + // could carry stale or hostile ETag values that would taint future + // conditional requests. A 304 confirms the existing ETag still matches, + // so leaving the stored value alone is correct. + // + // Reject empty-string ETags: an unquoted empty token is invalid per + // RFC 7232 ยง2.1, and sending `if-none-match: ` on the next request + // could be interpreted by some servers as "match anything" and pin + // the SDK to a permanent 304. + if (response.statusCode == 200) { + final etag = response.headers['etag']; + if (etag != null && etag.isNotEmpty) { + _lastEtag = etag; + } + } + + return ( + status: response.statusCode, + headers: response.headers, + body: response.body, + ); + } + + Uri _buildUri({required Selector basis}) { + final addedPath = _usePost + ? FDv2Endpoints.polling + : FDv2Endpoints.pollingGet(_contextEncoded); + + // Compose against the parsed base URI so a custom polling URL + // carrying its own query parameters (e.g. a relay proxy with a token) + // is preserved correctly. String concatenation against `_baseUri` + // would land the appended path inside the query component. + final basePath = _baseUri.path.endsWith('/') + ? _baseUri.path.substring(0, _baseUri.path.length - 1) + : _baseUri.path; + final mergedPath = '$basePath$addedPath'; + + // Use queryParametersAll so a base URL like `?dup=1&dup=2` round-trips + // both values; the simpler `queryParameters` map collapses duplicates. + final mergedQuery = {}; + mergedQuery.addAll(_baseUri.queryParametersAll); + if (_withReasons) { + mergedQuery['withReasons'] = 'true'; + } + if (basis.isNotEmpty && basis.state!.isNotEmpty) { + mergedQuery['basis'] = basis.state!; + } + + return _baseUri.replace( + path: mergedPath, + queryParameters: mergedQuery.isEmpty ? null : mergedQuery, + ); + } +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/source.dart b/packages/common_client/lib/src/data_sources/fdv2/source.dart new file mode 100644 index 0000000..bac1c3c --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/source.dart @@ -0,0 +1,44 @@ +import 'selector.dart'; +import 'source_result.dart'; + +/// A function that returns the current selector for a data source. +/// +/// The orchestrator owns the SDK's current selector. Sources read it +/// lazily on each request or reconnect via this getter, so they always +/// see the latest value across mode switches and recoveries. +typedef SelectorGetter = Selector Function(); + +/// A function that performs a single FDv2 poll and returns the result. +/// +/// Used by streaming sources to handle legacy `ping` events: when a ping +/// is received, the streaming source invokes the ping handler to fetch +/// the current payload via polling. +typedef PingHandler = Future Function(); + +/// A one-shot data source that produces a single result. +/// +/// Used during initialization to bring the SDK into a usable state from +/// cache, polling, or a streaming connection's first payload. +abstract interface class Initializer { + /// Runs the initializer, producing a single result. If [close] is called + /// before a result is produced, the returned future completes with a + /// shutdown [StatusResult]. + Future run(); + + /// Cancels in-progress work. Idempotent. + void close(); +} + +/// A long-lived data source that produces a stream of results. +/// +/// Used during steady-state operation to keep the SDK current via polling +/// or streaming. +abstract interface class Synchronizer { + /// Single-subscription stream of results. Cancelling the subscription + /// stops the synchronizer; starting a new subscription is not supported. + Stream get results; + + /// Cancels active work. Idempotent. A shutdown [StatusResult] is + /// emitted to any active subscriber before the stream closes. + void close(); +} diff --git a/packages/common_client/test/data_sources/fdv2/endpoints_test.dart b/packages/common_client/test/data_sources/fdv2/endpoints_test.dart new file mode 100644 index 0000000..69101ee --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/endpoints_test.dart @@ -0,0 +1,28 @@ +import 'package:launchdarkly_common_client/src/data_sources/fdv2/endpoints.dart'; +import 'package:test/test.dart'; + +void main() { + group('FDv2Endpoints', () { + test('polling path is the FDv2 polling endpoint', () { + expect(FDv2Endpoints.polling, equals('/sdk/poll/eval')); + }); + + test('streaming path is the FDv2 streaming endpoint', () { + expect(FDv2Endpoints.streaming, equals('/sdk/stream/eval')); + }); + + test('pollingGet appends the encoded context', () { + expect( + FDv2Endpoints.pollingGet('eyJrZXkiOiJ0ZXN0In0='), + equals('/sdk/poll/eval/eyJrZXkiOiJ0ZXN0In0='), + ); + }); + + test('streamingGet appends the encoded context', () { + expect( + FDv2Endpoints.streamingGet('eyJrZXkiOiJ0ZXN0In0='), + equals('/sdk/stream/eval/eyJrZXkiOiJ0ZXN0In0='), + ); + }); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/polling_base_test.dart b/packages/common_client/test/data_sources/fdv2/polling_base_test.dart new file mode 100644 index 0000000..3c7bcd9 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/polling_base_test.dart @@ -0,0 +1,645 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/polling_base.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/requestor.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_result.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +import 'support/capturing_log_adapter.dart'; + +FDv2PollingBase makePollingBase( + MockClient innerClient, { + DateTime Function()? now, +}) { + final requestor = FDv2Requestor( + logger: LDLogger(), + endpoints: ServiceEndpoints.custom(polling: 'https://example.test'), + contextEncoded: 'CTX', + contextJson: '{"key":"test"}', + usePost: false, + withReasons: false, + httpProperties: HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: innerClient, httpProperties: props), + ); + return FDv2PollingBase( + logger: LDLogger(), + requestor: requestor, + now: now, + ); +} + +/// Builds a complete xfer-full FDv2 events collection JSON body with a +/// single put-object for `flag-eval`. +String buildXferFullBody({ + String state = 'sel-1', + int targetVersion = 1, + int payloadVersion = 1, + String flagKey = 'my-flag', +}) { + return jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': [ + { + 'id': 'p1', + 'target': targetVersion, + 'intentCode': 'xfer-full', + 'reason': 'test', + } + ] + } + }, + { + 'event': 'put-object', + 'data': { + 'kind': 'flag-eval', + 'key': flagKey, + 'version': payloadVersion, + 'object': { + 'value': true, + 'version': payloadVersion, + 'variation': 0, + 'trackEvents': false, + }, + } + }, + { + 'event': 'payload-transferred', + 'data': { + 'state': state, + 'version': payloadVersion, + } + }, + ] + }); +} + +void main() { + group('200 response with valid payload', () { + test('produces a ChangeSetResult with the parsed payload', () async { + final mock = MockClient((request) async { + return http.Response( + buildXferFullBody(state: 'sel-99', payloadVersion: 99), 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + final cs = result as ChangeSetResult; + expect(cs.payload.type, equals(PayloadType.full)); + expect(cs.payload.selector.state, equals('sel-99')); + expect(cs.payload.selector.version, equals(99)); + expect(cs.payload.updates, hasLength(1)); + expect(cs.payload.updates[0].key, equals('my-flag')); + expect(cs.persist, isTrue); + }); + + test('propagates the x-ld-envid header to the result', () async { + final mock = MockClient((request) async { + return http.Response(buildXferFullBody(), 200, + headers: {'x-ld-envid': 'env-abc'}); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as ChangeSetResult).environmentId, equals('env-abc')); + }); + + test('sets freshness to the result of the now function', () async { + final fixedNow = DateTime.utc(2026, 4, 16, 12, 0, 0); + final mock = MockClient((request) async { + return http.Response(buildXferFullBody(), 200); + }); + + final base = makePollingBase(mock, now: () => fixedNow); + final result = await base.pollOnce(); + + expect((result as ChangeSetResult).freshness, equals(fixedNow)); + }); + }); + + group('304 Not Modified', () { + test('produces a ChangeSetResult with PayloadType.none', () async { + final mock = MockClient((request) async { + return http.Response('', 304); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + final cs = result as ChangeSetResult; + expect(cs.payload.type, equals(PayloadType.none)); + expect(cs.payload.updates, isEmpty); + expect(cs.persist, isTrue); + }); + }); + + group('FDv1 fallback', () { + test( + 'returns terminalError with fdv1Fallback=true when ' + 'x-ld-fd-fallback is true', () async { + final mock = MockClient((request) async { + return http.Response('', 200, headers: {'x-ld-fd-fallback': 'true'}); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + final status = result as StatusResult; + expect(status.state, equals(SourceState.terminalError)); + expect(status.fdv1Fallback, isTrue); + }); + + test('does not engage fallback when header is missing', () async { + final mock = MockClient((request) async { + return http.Response(buildXferFullBody(), 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + expect(result.fdv1Fallback, isFalse); + }); + }); + + group('HTTP error classification', () { + test('400 is interrupted (recoverable)', () async { + final mock = MockClient((request) async { + return http.Response('bad request', 400); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + final status = result as StatusResult; + expect(status.state, equals(SourceState.interrupted)); + expect(status.statusCode, equals(400)); + }); + + test('408 is interrupted', () async { + final mock = MockClient((request) async { + return http.Response('timeout', 408); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('429 is interrupted', () async { + final mock = MockClient((request) async { + return http.Response('rate limited', 429); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('401 is terminalError', () async { + final mock = MockClient((request) async { + return http.Response('unauthorized', 401); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.terminalError)); + }); + + test('403 is terminalError', () async { + final mock = MockClient((request) async { + return http.Response('forbidden', 403); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.terminalError)); + }); + + test('500 is interrupted (5xx is recoverable)', () async { + final mock = MockClient((request) async { + return http.Response('server error', 500); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + }); + + group('network failures', () { + test('returns interrupted when the requestor throws', () async { + final mock = MockClient((request) async { + throw Exception('connection refused'); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + }); + + group('malformed bodies', () { + test('returns interrupted when body is not valid JSON', () async { + final mock = MockClient((request) async { + return http.Response('not json', 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('returns interrupted when body is JSON but not an object', () async { + final mock = MockClient((request) async { + return http.Response('[1, 2, 3]', 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('returns interrupted when no payload-transferred is present', + () async { + final body = jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': [ + { + 'id': 'p1', + 'target': 1, + 'intentCode': 'xfer-full', + 'reason': 'test', + } + ] + } + } + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + }); + + group('protocol-level outcomes', () { + test('goodbye event produces a goodbye StatusResult', () async { + final body = jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': [ + { + 'id': 'p1', + 'target': 1, + 'intentCode': 'xfer-full', + 'reason': 'test', + } + ] + } + }, + { + 'event': 'goodbye', + 'data': {'reason': 'maintenance'} + }, + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.goodbye)); + }); + + test('server error event produces interrupted', () async { + final body = jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': [ + { + 'id': 'p1', + 'target': 1, + 'intentCode': 'xfer-full', + 'reason': 'test', + } + ] + } + }, + { + 'event': 'error', + 'data': {'payload_id': 'p1', 'reason': 'oops'} + }, + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('intent-none on a 200 produces a none change set', () async { + final body = jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': [ + { + 'id': 'p1', + 'target': 7, + 'intentCode': 'none', + 'reason': 'up-to-date', + } + ] + } + }, + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + final cs = result as ChangeSetResult; + expect(cs.payload.type, equals(PayloadType.none)); + expect(cs.payload.updates, isEmpty); + }); + + test('heartbeat-only response is interrupted', () async { + final body = jsonEncode({ + 'events': [ + {'event': 'heart-beat', 'data': {}} + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + }); + + group('malformed event shapes (do not throw)', () { + test('non-Map element in events array produces interrupted', () async { + final body = jsonEncode({ + 'events': [42] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('non-Map element in payloads array produces interrupted', () async { + final body = jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': ['not-an-object'] + } + } + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + + test('object field of put-object that is not a Map produces interrupted', + () async { + final body = jsonEncode({ + 'events': [ + { + 'event': 'server-intent', + 'data': { + 'payloads': [ + { + 'id': 'p1', + 'target': 1, + 'intentCode': 'xfer-full', + 'reason': 'test', + } + ] + } + }, + { + 'event': 'put-object', + 'data': { + 'kind': 'flag-eval', + 'key': 'k', + 'version': 1, + 'object': 'not-a-map', + } + }, + ] + }); + final mock = MockClient((request) async { + return http.Response(body, 200); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + // The cast inside PutObjectEvent.fromJson throws TypeError; the + // widened catch in _parseBody converts it to interrupted. + expect((result as StatusResult).state, equals(SourceState.interrupted)); + }); + }); + + group('FDv1 fallback precedence', () { + test('fallback header takes precedence over a 200 with valid payload', + () async { + final mock = MockClient((request) async { + return http.Response(buildXferFullBody(), 200, + headers: {'x-ld-fd-fallback': 'true'}); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + final status = result as StatusResult; + expect(status.state, equals(SourceState.terminalError)); + expect(status.fdv1Fallback, isTrue); + }); + + test('fallback header takes precedence over a 304', () async { + final mock = MockClient((request) async { + return http.Response('', 304, headers: {'x-ld-fd-fallback': 'true'}); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.terminalError)); + expect(result.fdv1Fallback, isTrue); + }); + + test('fallback header is matched case-insensitively', () async { + final mock = MockClient((request) async { + return http.Response('', 200, headers: {'x-ld-fd-fallback': 'TRUE'}); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect((result as StatusResult).state, equals(SourceState.terminalError)); + expect(result.fdv1Fallback, isTrue); + }); + + test('fallback header value other than true is ignored', () async { + final mock = MockClient((request) async { + return http.Response(buildXferFullBody(), 200, + headers: {'x-ld-fd-fallback': 'false'}); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + expect(result, isA()); + expect(result.fdv1Fallback, isFalse); + }); + }); + + group('error message sanitization', () { + test('exception message is not echoed verbatim into the result', () async { + const sensitive = '203.0.113.5:443 cert CN=internal.example.com'; + final mock = MockClient((request) async { + throw Exception(sensitive); + }); + + final base = makePollingBase(mock); + final result = await base.pollOnce(); + + final status = result as StatusResult; + expect(status.state, equals(SourceState.interrupted)); + expect(status.message, isNotNull); + expect(status.message, isNot(contains(sensitive))); + }); + + test('warn log on a network error does not contain the encoded context', + () async { + // http.ClientException's toString() formats as + // 'ClientException: , uri='. The full URL embeds the + // base64url-encoded context in GET mode. The polling base must + // never log the raw exception. + final captured = CapturingLogAdapter(); + final logger = LDLogger(adapter: captured, level: LDLogLevel.debug); + final mock = MockClient((request) async { + throw http.ClientException('Connection refused', request.url); + }); + + final requestor = FDv2Requestor( + logger: logger, + endpoints: ServiceEndpoints.custom(polling: 'https://example.test'), + contextEncoded: 'SECRET-ENCODED-CONTEXT', + contextJson: '{"key":"x"}', + usePost: false, + withReasons: false, + httpProperties: HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: mock, httpProperties: props), + ); + final base = FDv2PollingBase(logger: logger, requestor: requestor); + await base.pollOnce(); + + for (final message in captured.messages) { + expect(message, isNot(contains('SECRET-ENCODED-CONTEXT'))); + } + }); + + test('TimeoutException maps to "timed out"', () async { + final mock = MockClient((request) async { + throw TimeoutException('exceeded', const Duration(seconds: 1)); + }); + final base = makePollingBase(mock); + final result = await base.pollOnce(); + expect( + (result as StatusResult).message, + equals('Polling request timed out'), + ); + }); + + test('http.ClientException maps to "Network error"', () async { + final mock = MockClient((request) async { + throw http.ClientException( + 'Connection refused', Uri.parse('https://example.test')); + }); + final base = makePollingBase(mock); + final result = await base.pollOnce(); + expect( + (result as StatusResult).message, + equals('Network error during polling request'), + ); + }); + + test('an unknown exception type falls back to a generic message', () async { + final mock = MockClient((request) async { + throw Exception('something'); + }); + final base = makePollingBase(mock); + final result = await base.pollOnce(); + expect( + (result as StatusResult).message, + equals('Polling request failed'), + ); + }); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/requestor_test.dart b/packages/common_client/test/data_sources/fdv2/requestor_test.dart new file mode 100644 index 0000000..3b7418e --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/requestor_test.dart @@ -0,0 +1,547 @@ +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/requestor.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/selector.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +import 'support/capturing_log_adapter.dart'; + +FDv2Requestor makeRequestor( + MockClient innerClient, { + bool usePost = false, + bool withReasons = false, + String contextEncoded = 'eyJrZXkiOiJ0ZXN0In0=', + String contextJson = '{"key":"test"}', + HttpProperties? httpProperties, +}) { + return FDv2Requestor( + logger: LDLogger(), + endpoints: ServiceEndpoints.custom(polling: 'https://example.test'), + contextEncoded: contextEncoded, + contextJson: contextJson, + usePost: usePost, + withReasons: withReasons, + httpProperties: httpProperties ?? HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: innerClient, httpProperties: props), + ); +} + +void main() { + group('GET requests', () { + test('builds polling GET URL with encoded context in path', () async { + late Uri capturedUri; + late String capturedMethod; + final mock = MockClient((request) async { + capturedUri = request.url; + capturedMethod = request.method; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock, contextEncoded: 'ENC123'); + await requestor.request(); + + expect(capturedMethod, equals('GET')); + expect(capturedUri.path, equals('/sdk/poll/eval/ENC123')); + expect(capturedUri.host, equals('example.test')); + }); + + test('does not send a body on GET', () async { + late String capturedBody; + final mock = MockClient((request) async { + capturedBody = request.body; + return http.Response('{}', 200); + }); + final requestor = makeRequestor(mock); + await requestor.request(); + + expect(capturedBody, isEmpty); + }); + }); + + group('POST requests', () { + test('builds polling POST URL without context in path', () async { + late Uri capturedUri; + late String capturedMethod; + final mock = MockClient((request) async { + capturedUri = request.url; + capturedMethod = request.method; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock, usePost: true); + await requestor.request(); + + expect(capturedMethod, equals('POST')); + expect(capturedUri.path, equals('/sdk/poll/eval')); + }); + + test('sends the context JSON as the request body', () async { + late String capturedBody; + final mock = MockClient((request) async { + capturedBody = request.body; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor( + mock, + usePost: true, + contextJson: '{"key":"alice"}', + ); + await requestor.request(); + + expect(capturedBody, equals('{"key":"alice"}')); + }); + + test('sets content-type header on POST', () async { + late http.BaseRequest capturedRequest; + final mock = MockClient((request) async { + capturedRequest = request; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock, usePost: true); + await requestor.request(); + + expect( + capturedRequest.headers, + containsPair('content-type', 'application/json'), + ); + }); + }); + + group('query parameters', () { + test('omits basis when selector is empty', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + + expect(capturedUri.queryParameters.containsKey('basis'), isFalse); + }); + + test('includes basis when selector is non-empty', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request( + basis: Selector(state: '(p:abc:42)', version: 42)); + + expect(capturedUri.queryParameters['basis'], equals('(p:abc:42)')); + }); + + test('includes withReasons=true when configured', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock, withReasons: true); + await requestor.request(); + + expect(capturedUri.queryParameters['withReasons'], equals('true')); + }); + + test('omits withReasons when not configured', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + + expect(capturedUri.queryParameters.containsKey('withReasons'), isFalse); + }); + }); + + group('etag handling', () { + test('does not send if-none-match on the first request', () async { + Map? capturedHeaders; + final mock = MockClient((request) async { + capturedHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + + expect(capturedHeaders!.containsKey('if-none-match'), isFalse); + }); + + test('sends if-none-match on subsequent requests', () async { + var requestNumber = 0; + Map? secondRequestHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('{}', 200, headers: {'etag': 'abc-123'}); + } + secondRequestHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + + expect( + secondRequestHeaders, + containsPair('if-none-match', 'abc-123'), + ); + }); + + test('updates etag when a new one is returned', () async { + var requestNumber = 0; + Map? thirdRequestHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('{}', 200, headers: {'etag': 'abc-123'}); + } + if (requestNumber == 2) { + return http.Response('{}', 200, headers: {'etag': 'xyz-456'}); + } + thirdRequestHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + await requestor.request(); + + expect( + thirdRequestHeaders, + containsPair('if-none-match', 'xyz-456'), + ); + }); + }); + + group('response shape', () { + test('returns status, headers, and body', () async { + final mock = MockClient((request) async { + return http.Response('{"key":"value"}', 200, + headers: {'x-ld-envid': 'env-1'}); + }); + + final requestor = makeRequestor(mock); + final response = await requestor.request(); + + expect(response.status, equals(200)); + expect(response.headers, containsPair('x-ld-envid', 'env-1')); + expect(response.body, equals('{"key":"value"}')); + }); + + test('propagates non-success status codes', () async { + final mock = MockClient((request) async { + return http.Response('error', 500); + }); + + final requestor = makeRequestor(mock); + final response = await requestor.request(); + + expect(response.status, equals(500)); + expect(response.body, equals('error')); + }); + }); + + group('errors', () { + test('throws on network error', () async { + final mock = MockClient((request) async { + throw Exception('connection refused'); + }); + + final requestor = makeRequestor(mock); + + expect( + () => requestor.request(), + throwsException, + ); + }); + }); + + group('etag is only persisted on 200', () { + test('etag returned on 4xx is not sent on next request', () async { + var requestNumber = 0; + Map? secondRequestHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('error', 400, headers: {'etag': 'poisoned'}); + } + secondRequestHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + + expect(secondRequestHeaders!.containsKey('if-none-match'), isFalse); + }); + + test('etag returned on 5xx is not sent on next request', () async { + var requestNumber = 0; + Map? secondRequestHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('error', 500, headers: {'etag': 'poisoned'}); + } + secondRequestHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + + expect(secondRequestHeaders!.containsKey('if-none-match'), isFalse); + }); + + test( + '304 does not overwrite the previously stored etag ' + '(it confirms the existing one)', () async { + var requestNumber = 0; + Map? thirdRequestHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('{}', 200, headers: {'etag': 'first'}); + } + if (requestNumber == 2) { + // 304 returned without an etag header -- the SDK should still + // remember "first" from the prior 200 response. + return http.Response('', 304); + } + thirdRequestHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + await requestor.request(); + + expect(thirdRequestHeaders, containsPair('if-none-match', 'first')); + }); + }); + + group('custom polling URL with embedded query parameters', () { + test( + 'preserves query parameters from the base URL ' + 'and appends our own', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = FDv2Requestor( + logger: LDLogger(), + endpoints: ServiceEndpoints.custom( + polling: 'https://relay.example.com/prefix?token=abc123'), + contextEncoded: 'CTX', + contextJson: '{"key":"x"}', + usePost: false, + withReasons: true, + httpProperties: HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: mock, httpProperties: props), + ); + await requestor.request(basis: Selector(state: 'sel-1', version: 1)); + + expect(capturedUri.host, equals('relay.example.com')); + expect(capturedUri.path, equals('/prefix/sdk/poll/eval/CTX')); + expect(capturedUri.queryParameters['token'], equals('abc123')); + expect(capturedUri.queryParameters['withReasons'], equals('true')); + expect(capturedUri.queryParameters['basis'], equals('sel-1')); + }); + }); + + group('basis and withReasons with POST', () { + test('sends basis as query parameter even on POST', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock, usePost: true); + await requestor.request(basis: Selector(state: 'sel-2', version: 2)); + + expect(capturedUri.queryParameters['basis'], equals('sel-2')); + }); + + test('sends withReasons=true as query parameter on POST', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock, usePost: true, withReasons: true); + await requestor.request(); + + expect(capturedUri.queryParameters['withReasons'], equals('true')); + }); + }); + + group('selector edge cases', () { + test('basis is omitted when state is empty even if isNotEmpty', () async { + // Defensive: a Selector(state: '', version: 1) constructs as + // isEmpty=false. The requestor should still not send basis=. + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(basis: Selector(state: '', version: 1)); + + expect(capturedUri.queryParameters.containsKey('basis'), isFalse); + }); + }); + + group('debug logging does not leak the encoded context', () { + test('the context segment of the URL is not logged', () async { + final captured = CapturingLogAdapter(); + final logger = LDLogger(adapter: captured, level: LDLogLevel.debug); + final mock = MockClient((request) async { + return http.Response('{}', 200); + }); + + final requestor = FDv2Requestor( + logger: logger, + endpoints: ServiceEndpoints.custom(polling: 'https://example.test'), + contextEncoded: 'SECRET-ENCODED-CONTEXT', + contextJson: '{"key":"x"}', + usePost: false, + withReasons: false, + httpProperties: HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: mock, httpProperties: props), + ); + await requestor.request(); + + for (final message in captured.messages) { + expect(message, isNot(contains('SECRET-ENCODED-CONTEXT'))); + } + }); + }); + + group('etag edge cases', () { + test('empty-string etag is not stored', () async { + var requestNumber = 0; + Map? secondHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('{}', 200, headers: {'etag': ''}); + } + secondHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + + expect(secondHeaders!.containsKey('if-none-match'), isFalse); + }); + + test('304 with a new etag does NOT overwrite the stored etag', () async { + // Pinning current behavior: a 304 response confirms the ETag the + // client already sent; if the server attaches a different ETag we + // ignore it and continue to use the original. Updating on 304 would + // mean trusting an unverified value. + var requestNumber = 0; + Map? thirdHeaders; + final mock = MockClient((request) async { + requestNumber++; + if (requestNumber == 1) { + return http.Response('{}', 200, headers: {'etag': 'first'}); + } + if (requestNumber == 2) { + return http.Response('', 304, headers: {'etag': 'rotated'}); + } + thirdHeaders = request.headers; + return http.Response('{}', 200); + }); + + final requestor = makeRequestor(mock); + await requestor.request(); + await requestor.request(); + await requestor.request(); + + expect(thirdHeaders, containsPair('if-none-match', 'first')); + }); + }); + + group('base URL trailing slash', () { + test('does not produce a double-slash in the merged path', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = FDv2Requestor( + logger: LDLogger(), + endpoints: ServiceEndpoints.custom( + polling: 'https://relay.example.com/prefix/'), + contextEncoded: 'CTX', + contextJson: '{"k":"v"}', + usePost: false, + withReasons: false, + httpProperties: HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: mock, httpProperties: props), + ); + await requestor.request(); + + expect(capturedUri.path, equals('/prefix/sdk/poll/eval/CTX')); + }); + }); + + group('base URL with duplicate-key query parameters', () { + test('preserves all values for repeated keys', () async { + late Uri capturedUri; + final mock = MockClient((request) async { + capturedUri = request.url; + return http.Response('{}', 200); + }); + + final requestor = FDv2Requestor( + logger: LDLogger(), + endpoints: ServiceEndpoints.custom( + polling: 'https://relay.example.com/?tag=a&tag=b'), + contextEncoded: 'CTX', + contextJson: '{"k":"v"}', + usePost: false, + withReasons: false, + httpProperties: HttpProperties(), + httpClientFactory: (props) => + HttpClient(client: mock, httpProperties: props), + ); + await requestor.request(); + + expect(capturedUri.queryParametersAll['tag'], equals(['a', 'b'])); + }); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/support/capturing_log_adapter.dart b/packages/common_client/test/data_sources/fdv2/support/capturing_log_adapter.dart new file mode 100644 index 0000000..646bc2e --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/support/capturing_log_adapter.dart @@ -0,0 +1,15 @@ +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; + +/// An [LDLogAdapter] that captures every log record into a list, for +/// assertions about what does or does not appear in log output. +class CapturingLogAdapter implements LDLogAdapter { + final List records = []; + + /// Convenience: just the message strings. + List get messages => records.map((r) => r.message).toList(); + + @override + void log(LDLogRecord record) { + records.add(record); + } +}