diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d71611b1..dad8ab25 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -29,6 +29,7 @@ - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user - `TokenManager.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it - `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token +- `SystemEnvironmentManager.updateEnvironment` now sanitizes the passed `SystemEnvironment`, so an integrator can enrich the Stream client header without changing the SDK identity it reports ## 0.4.0 diff --git a/packages/stream_core/lib/src/api/system_environment_manager.dart b/packages/stream_core/lib/src/api/system_environment_manager.dart index 96986bdc..8752e8d6 100644 --- a/packages/stream_core/lib/src/api/system_environment_manager.dart +++ b/packages/stream_core/lib/src/api/system_environment_manager.dart @@ -1,15 +1,31 @@ -// ignore_for_file: use_setters_to_change_properties - import 'system_environment.dart'; /// {@template systemEnvironmentManager} /// A manager class to handle the current [SystemEnvironment]. +/// +/// The [SystemEnvironment] passed to the constructor is the SDK-owned +/// baseline, and is trusted as-is: only the SDK that builds the manager is +/// meant to provide it. Every environment passed to [updateEnvironment] is +/// sanitized against that baseline, so an integrator can enrich the Stream +/// client header without changing the SDK identity it reports. /// {@endtemplate} class SystemEnvironmentManager { /// {@macro systemEnvironmentManager} SystemEnvironmentManager({ - required this._environment, - }); + required SystemEnvironment environment, + }) : _sdkName = environment.sdkName, + _sdkVersion = environment.sdkVersion, + _osName = environment.osName, + _environment = environment; + + // The SDK-owned values every update is sanitized against. + // + // Snapshotted rather than kept as a SystemEnvironment reference: the type is + // not final, so a subtype could return something different on every getter + // read and drift the values an update is supposed to be locked to. + final String _sdkName; + final String _sdkVersion; + final String? _osName; /// Returns the Stream client user agent string based on the current /// [environment] value. @@ -20,8 +36,72 @@ class SystemEnvironmentManager { SystemEnvironment _environment; /// Updates the current [SystemEnvironment]. + /// + /// The passed [environment] is sanitized against the environment this manager + /// was constructed with, so the SDK identity reported in the Stream client + /// header cannot be changed after construction. + /// + /// The following fields are applied as passed. + /// + /// - [SystemEnvironment.appName] + /// - [SystemEnvironment.appVersion] + /// - [SystemEnvironment.osVersion] + /// - [SystemEnvironment.deviceModel] + /// + /// The following fields keep the value given to the constructor, and custom + /// values for them are ignored. + /// + /// - [SystemEnvironment.sdkName] + /// - [SystemEnvironment.sdkVersion] + /// - [SystemEnvironment.osName] + /// + /// [SystemEnvironment.sdkIdentifier] is a partial exception: only the `dart` + /// to `flutter` promotion is accepted. Any other value, including a `flutter` + /// to `dart` demotion or an unrecognized identifier, is ignored. + /// + /// Fields are replaced rather than merged, so a field left out of + /// [environment] is cleared rather than carried over from a previous update. + /// + /// For a manager constructed with `sdkName` `stream-feeds`, `sdkIdentifier` + /// `dart` and `sdkVersion` `1.0.0`: + /// + /// ```dart + /// manager.updateEnvironment( + /// const SystemEnvironment( + /// sdkName: 'spoofed', // Ignored. + /// sdkIdentifier: 'dart', + /// sdkVersion: '9.9.9', // Ignored. + /// appName: 'MyApp', // Applied. + /// ), + /// ); + /// + /// print(manager.userAgent); // stream-feeds-dart-v1.0.0|app=MyApp + /// ``` void updateEnvironment(SystemEnvironment environment) { - _environment = environment; + _environment = _sanitize(environment); + } + + // Rebuilds the passed environment with the SDK-owned fields restored from + // the baseline. + // + // Always returns a fresh instance. SystemEnvironment is not final, so the + // passed value may be a subtype whose getters return something different on + // every read; its fields are copied out once here and it is never handed on + // directly. Do not add a fast path that returns the passed instance. + SystemEnvironment _sanitize(SystemEnvironment environment) { + final current = _SdkIdentifier(_environment.sdkIdentifier); + final proposed = _SdkIdentifier(environment.sdkIdentifier); + + return SystemEnvironment( + sdkName: _sdkName, + sdkIdentifier: current.resolveUpdate(proposed), + sdkVersion: _sdkVersion, + appName: environment.appName, + appVersion: environment.appVersion, + osName: _osName, + osVersion: environment.osVersion, + deviceModel: environment.deviceModel, + ); } } @@ -53,3 +133,24 @@ extension XStreamClientHeaderExtension on SystemEnvironment { ].nonNulls.join('|'); } } + +// Known SDK identifiers, ranked so that the dart to flutter transition is +// one-way and unrecognized identifiers are never accepted. +extension type const _SdkIdentifier(String value) implements String { + static const dart = _SdkIdentifier('dart'); + static const flutter = _SdkIdentifier('flutter'); + + int get _precedence => switch (this) { + dart => 0, + flutter => 1, + _ => -1, + }; + + // Returns the identifier to keep when [proposed] is offered as a + // replacement for this one. + _SdkIdentifier resolveUpdate(_SdkIdentifier proposed) { + if (proposed._precedence < 0) return this; + if (proposed._precedence < _precedence) return this; + return proposed; + } +} diff --git a/packages/stream_core/test/api/system_environment_manager_test.dart b/packages/stream_core/test/api/system_environment_manager_test.dart new file mode 100644 index 00000000..8f140936 --- /dev/null +++ b/packages/stream_core/test/api/system_environment_manager_test.dart @@ -0,0 +1,176 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('keeps the environment passed to the constructor', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + expect(manager.environment.sdkName, 'stream-sdk'); + expect(manager.environment.sdkIdentifier, 'dart'); + expect(manager.environment.sdkVersion, '1.2.3'); + expect(manager.environment.osName, 'ios'); + }); + + test('builds the user agent from the current environment', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + expect(manager.userAgent, 'stream-sdk-dart-v1.2.3|os=ios'); + }); + + test('ignores sdkName, sdkVersion and osName passed to an update', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager.updateEnvironment( + const SystemEnvironment( + sdkName: 'stream-chat', + sdkIdentifier: 'dart', + sdkVersion: '99.0.0', + osName: 'spoofed-os', + ), + ); + + expect(manager.environment.sdkName, 'stream-sdk'); + expect(manager.environment.sdkVersion, '1.2.3'); + expect(manager.environment.osName, 'ios'); + }); + + test('passes app, os version and device fields through on an update', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager.updateEnvironment( + const SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.2.3', + appName: 'test-app', + appVersion: '2.0.0', + osVersion: '14', + deviceModel: 'Pixel 7', + ), + ); + + expect(manager.environment.appName, 'test-app'); + expect(manager.environment.appVersion, '2.0.0'); + expect(manager.environment.osVersion, '14'); + expect(manager.environment.deviceModel, 'Pixel 7'); + }); + + test('promotes sdkIdentifier from dart to flutter', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager.updateEnvironment(_identifiedAs('flutter')); + + expect(manager.environment.sdkIdentifier, 'flutter'); + }); + + test('ignores a demotion of sdkIdentifier from flutter to dart', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager + ..updateEnvironment(_identifiedAs('flutter')) + ..updateEnvironment(_identifiedAs('dart')); + + expect(manager.environment.sdkIdentifier, 'flutter'); + }); + + test('keeps a promoted sdkIdentifier across later updates', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager + ..updateEnvironment(_identifiedAs('flutter')) + ..updateEnvironment(_identifiedAs('dart', appName: 'test-app')); + + expect(manager.environment.sdkIdentifier, 'flutter'); + expect(manager.environment.appName, 'test-app'); + }); + + test('ignores an unrecognized sdkIdentifier', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager.updateEnvironment(_identifiedAs('android')); + + expect(manager.environment.sdkIdentifier, 'dart'); + }); + + test('ignores an unrecognized sdkIdentifier when the baseline is unrecognized too', () { + final manager = SystemEnvironmentManager(environment: _identifiedAs('dart-io')); + + manager.updateEnvironment(_identifiedAs('android')); + + expect(manager.environment.sdkIdentifier, 'dart-io'); + }); + + test('accepts a known sdkIdentifier when the baseline is unrecognized', () { + final manager = SystemEnvironmentManager(environment: _identifiedAs('dart-io')); + + manager.updateEnvironment(_identifiedAs('flutter')); + + expect(manager.environment.sdkIdentifier, 'flutter'); + }); + + test('keeps the locked fields at the baseline across repeated updates', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager + ..updateEnvironment(_identifiedAs('dart', appName: 'first-app')) + ..updateEnvironment(_identifiedAs('dart', appName: 'second-app')); + + expect(manager.environment.appName, 'second-app'); + expect(manager.environment.sdkName, 'stream-sdk'); + expect(manager.environment.sdkVersion, '1.2.3'); + expect(manager.environment.osName, 'ios'); + }); + + test('keeps the locked fields fixed when the baseline object mutates', () { + final manager = SystemEnvironmentManager(environment: _MutatingBaseline()); + + final observed = []; + for (var i = 0; i < 3; i++) { + manager.updateEnvironment(_identifiedAs('dart')); + observed.add(manager.environment.sdkName); + } + + expect(observed, ['v0', 'v0', 'v0']); + }); + + test('clears a pass-through field left out of a later update', () { + final manager = SystemEnvironmentManager(environment: _baseline); + + manager + ..updateEnvironment(_identifiedAs('dart', appName: 'test-app')) + ..updateEnvironment(_identifiedAs('dart')); + + expect(manager.environment.appName, isNull); + }); +} + +// A baseline whose sdkName changes on every getter read. SystemEnvironment is +// not final, so the manager must snapshot the SDK-owned values at construction +// rather than re-read them from the instance it was given. +class _MutatingBaseline extends SystemEnvironment { + _MutatingBaseline() : super(sdkName: 'v0', sdkIdentifier: 'dart', sdkVersion: '1.0.0'); + + var _reads = 0; + + @override + String get sdkName => 'v${_reads++}'; +} + +// The baseline a product SDK owns and passes to the constructor. +const _baseline = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.2.3', + osName: 'ios', +); + +// An update as an integrator would send it: only the identifier and the +// overridable fields differ from the baseline. +SystemEnvironment _identifiedAs(String sdkIdentifier, {String? appName}) { + return SystemEnvironment( + sdkName: _baseline.sdkName, + sdkIdentifier: sdkIdentifier, + sdkVersion: _baseline.sdkVersion, + appName: appName, + ); +} diff --git a/packages/stream_core/test/api/x_stream_client_header_extension_test.dart b/packages/stream_core/test/api/x_stream_client_header_extension_test.dart new file mode 100644 index 00000000..5be22b6f --- /dev/null +++ b/packages/stream_core/test/api/x_stream_client_header_extension_test.dart @@ -0,0 +1,114 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('generates a minimal header with the required fields only', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0'); + }); + + test('includes the app name when available', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + appName: 'test-app', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0|app=test-app'); + }); + + test('includes the app version when available', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + appVersion: '2.0.0', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0|app_version=2.0.0'); + }); + + test('includes the OS name and version together', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + osName: 'ios', + osVersion: '16.0', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0|os=ios 16.0'); + }); + + test('includes the OS name alone when the version is null', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + osName: 'android', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0|os=android'); + }); + + test('omits the OS segment when the name is null but the version is not', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + osVersion: '16.0', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0'); + }); + + test('includes the device model when available', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + deviceModel: 'iPhone 14', + ); + + expect(environment.xStreamClientHeader, 'stream-sdk-dart-v1.0.0|device_model=iPhone 14'); + }); + + test('includes every segment in order when all fields are available', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + appName: 'test-app', + appVersion: '2.0.0', + osName: 'ios', + osVersion: '16.0', + deviceModel: 'iPhone 14', + ); + + expect( + environment.xStreamClientHeader, + 'stream-sdk-dart-v1.0.0|app=test-app|app_version=2.0.0|os=ios 16.0|device_model=iPhone 14', + ); + }); + + test('skips the null values between populated ones', () { + const environment = SystemEnvironment( + sdkName: 'stream-sdk', + sdkIdentifier: 'dart', + sdkVersion: '1.0.0', + appVersion: '2.0.0', + deviceModel: 'iPhone 14', + ); + + expect( + environment.xStreamClientHeader, + 'stream-sdk-dart-v1.0.0|app_version=2.0.0|device_model=iPhone 14', + ); + }); +}