From f813cb720421f9a6cbf635c3e068743cf1f14c5e Mon Sep 17 00:00:00 2001 From: Razvan Lung Date: Sun, 26 Apr 2026 05:17:05 +0300 Subject: [PATCH] feat(remote_config): add Firebase Remote Config support * feat: add Firebase Remote Config support Implements Firebase Remote Config for the Dart Admin SDK with full template management plus server-side template evaluation. --- .github/workflows/build.yml | 9 + packages/firebase_admin_sdk/CHANGELOG.md | 1 + packages/firebase_admin_sdk/README.md | 81 +- packages/firebase_admin_sdk/example/README.md | 1 + .../example/bin/example.dart | 4 + .../example/lib/remote_config_example.dart | 118 ++ .../firebase_admin_sdk/lib/remote_config.dart | 21 + packages/firebase_admin_sdk/lib/src/app.dart | 1 + .../lib/src/app/firebase_app.dart | 5 + .../lib/src/app/firebase_service.dart | 1 + .../remote_config/condition_evaluator.dart | 281 ++++ .../lib/src/remote_config/remote_config.dart | 302 ++++ .../src/remote_config/remote_config_api.dart | 1350 +++++++++++++++++ .../remote_config_exception.dart | 147 ++ .../remote_config_http_client.dart | 193 +++ .../remote_config_request_handler.dart | 163 ++ packages/firebase_admin_sdk/pubspec.yaml | 1 + .../remote_config/remote_config_test.dart | 319 ++++ .../condition_evaluator_test.dart | 610 ++++++++ .../remote_config/remote_config_api_test.dart | 425 ++++++ .../remote_config_exception_test.dart | 114 ++ .../remote_config/remote_config_test.dart | 381 +++++ 22 files changed, 4527 insertions(+), 1 deletion(-) create mode 100644 packages/firebase_admin_sdk/example/lib/remote_config_example.dart create mode 100644 packages/firebase_admin_sdk/lib/remote_config.dart create mode 100644 packages/firebase_admin_sdk/lib/src/remote_config/condition_evaluator.dart create mode 100644 packages/firebase_admin_sdk/lib/src/remote_config/remote_config.dart create mode 100644 packages/firebase_admin_sdk/lib/src/remote_config/remote_config_api.dart create mode 100644 packages/firebase_admin_sdk/lib/src/remote_config/remote_config_exception.dart create mode 100644 packages/firebase_admin_sdk/lib/src/remote_config/remote_config_http_client.dart create mode 100644 packages/firebase_admin_sdk/lib/src/remote_config/remote_config_request_handler.dart create mode 100644 packages/firebase_admin_sdk/test/integration/remote_config/remote_config_test.dart create mode 100644 packages/firebase_admin_sdk/test/unit/remote_config/condition_evaluator_test.dart create mode 100644 packages/firebase_admin_sdk/test/unit/remote_config/remote_config_api_test.dart create mode 100644 packages/firebase_admin_sdk/test/unit/remote_config/remote_config_exception_test.dart create mode 100644 packages/firebase_admin_sdk/test/unit/remote_config/remote_config_test.dart diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0ad8546..aa1cdad5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -287,6 +287,15 @@ jobs: - name: Run WIF auth test run: dart test test/integration/app/firebase_app_prod_test.dart --concurrency=1 --tags wif + - name: Run Remote Config integration tests + env: + SERVICE_ACCOUNT: ${{ secrets.SERVICE_ACCOUNT }} + run: | + project_id="${SERVICE_ACCOUNT##*@}" + project_id="${project_id%%.iam.gserviceaccount.com}" + RC_TEST_PROJECT_ID="$project_id" \ + dart test test/integration/remote_config/ --concurrency=1 + publish: name: Publish verification runs-on: ubuntu-latest diff --git a/packages/firebase_admin_sdk/CHANGELOG.md b/packages/firebase_admin_sdk/CHANGELOG.md index cc591dc6..b2498317 100644 --- a/packages/firebase_admin_sdk/CHANGELOG.md +++ b/packages/firebase_admin_sdk/CHANGELOG.md @@ -1,5 +1,6 @@ ## 0.5.2-wip +- Add Remote Config support: template management and server-side template evaluation. - Remove dependency on `package:equatable`. - Make `Query`, `CollectionReference`, `DocumentReference`, and `CollectionGroup` mockable. - `Credential.createClient(List scopes)` — create an authenticated `AuthClient` directly diff --git a/packages/firebase_admin_sdk/README.md b/packages/firebase_admin_sdk/README.md index 33c50f88..9e6503a1 100644 --- a/packages/firebase_admin_sdk/README.md +++ b/packages/firebase_admin_sdk/README.md @@ -18,6 +18,7 @@ - [App Check](#app-check) - [Firestore](#firestore) - [Functions](#functions) + - [Remote Config](#remote-config) - [Messaging](#messaging) - [Storage](#storage) - [Security Rules](#security-rules) @@ -57,7 +58,7 @@ The Firebase Admin Dart SDK currently supports the following Firebase services: | Machine Learning | 🔴 | | | Messaging | 🟢 | | | Project Management | 🔴 | | -| Remote Config | 🔴 | | +| Remote Config | 🟢 | | | Security Rules | 🟢 | | | Storage | 🟢 | Via [package:google_cloud_storage] | @@ -583,6 +584,84 @@ await queue.enqueue( await queue.delete('payment-order-456'); ``` +### Remote Config + +```dart +import 'package:firebase_admin_sdk/firebase_admin_sdk.dart'; +import 'package:firebase_admin_sdk/remote_config.dart'; + +final app = FirebaseApp.initializeApp(); +final remoteConfig = app.remoteConfig(); +``` + +#### getTemplate / publishTemplate + +```dart +// Read the active template, edit one parameter, and publish. +final template = await remoteConfig.getTemplate(); +final updated = RemoteConfigTemplate( + etag: template.etag, + conditions: template.conditions, + parameters: { + ...template.parameters, + 'welcome_message': RemoteConfigParameter( + defaultValue: const ExplicitParameterValue(value: 'Hello'), + description: 'Updated greeting', + ), + }, + parameterGroups: template.parameterGroups, + version: template.version, +); +final published = await remoteConfig.publishTemplate(updated); +print('Published version: ${published.version?.versionNumber}'); +``` + +#### validateTemplate + +```dart +// Server-side validation without publishing. +final validated = await remoteConfig.validateTemplate(template); +print('Template OK; etag: ${validated.etag}'); +``` + +#### listVersions / rollback + +```dart +final versions = await remoteConfig.listVersions( + ListVersionsOptions(pageSize: 10), +); +for (final v in versions.versions) { + print('v${v.versionNumber}: ${v.description}'); +} + +// Roll back to a previous version. +await remoteConfig.rollback('5'); +``` + +#### Server-side evaluation + +Use [`getServerTemplate`](https://firebase.google.com/docs/remote-config/server-side-config) to fetch a template that the SDK can evaluate locally — useful for runtime feature flags, A/B test bucketing, and server-rendered configuration. + +```dart +final template = await remoteConfig.getServerTemplate( + defaultConfig: {'enable_new_ui': false, 'max_items': 50}, +); + +// Evaluate against an EvaluationContext: randomizationId is used by percent +// conditions, custom signals are used by string/numeric/semver conditions. +final config = template.evaluate( + EvaluationContext( + randomizationId: '', + customSignals: {'app_version': '2.3.1', 'country': 'US'}, + ), +); + +if (config.getBoolean('enable_new_ui')) { + // ... +} +print('max items: ${config.getInt('max_items')}'); +``` + ### Messaging ```dart diff --git a/packages/firebase_admin_sdk/example/README.md b/packages/firebase_admin_sdk/example/README.md index fb59c2dc..78a91b47 100644 --- a/packages/firebase_admin_sdk/example/README.md +++ b/packages/firebase_admin_sdk/example/README.md @@ -46,5 +46,6 @@ Some examples require a real project and credentials and are commented out by de - **App Check** - **Messaging** - **Security Rules** +- **Remote Config** You can uncomment them in `bin/example.dart` to try them out if you have a properly configured project. diff --git a/packages/firebase_admin_sdk/example/bin/example.dart b/packages/firebase_admin_sdk/example/bin/example.dart index d22d284e..b5b8478a 100644 --- a/packages/firebase_admin_sdk/example/bin/example.dart +++ b/packages/firebase_admin_sdk/example/bin/example.dart @@ -21,6 +21,7 @@ import 'package:firebase_admin_sdk_example/auth_example.dart'; import 'package:firebase_admin_sdk_example/firestore_example.dart'; import 'package:firebase_admin_sdk_example/functions_example.dart'; import 'package:firebase_admin_sdk_example/messaging_example.dart'; +import 'package:firebase_admin_sdk_example/remote_config_example.dart'; import 'package:firebase_admin_sdk_example/security_rules_example.dart'; import 'package:firebase_admin_sdk_example/storage_example.dart'; @@ -55,6 +56,9 @@ Future main() async { // Uncomment to run security rules example (requires a real project and credentials) // await securityRulesExample(admin); + + // Uncomment to run remote config example (requires a real project and credentials) + // await remoteConfigExample(admin); } finally { await admin.close(); } diff --git a/packages/firebase_admin_sdk/example/lib/remote_config_example.dart b/packages/firebase_admin_sdk/example/lib/remote_config_example.dart new file mode 100644 index 00000000..953fbbcb --- /dev/null +++ b/packages/firebase_admin_sdk/example/lib/remote_config_example.dart @@ -0,0 +1,118 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:firebase_admin_sdk/firebase_admin_sdk.dart'; +import 'package:firebase_admin_sdk/remote_config.dart'; + +Future remoteConfigExample(FirebaseApp admin) async { + print('\n### Remote Config Example ###\n'); + + final remoteConfig = admin.remoteConfig(); + + // Example 1: Read the active template. + RemoteConfigTemplate? template; + try { + print('> Fetching active template...\n'); + template = await remoteConfig.getTemplate(); + print('Template fetched!'); + print(' - ETag: ${template.etag}'); + print(' - Conditions: ${template.conditions.length}'); + print(' - Parameters: ${template.parameters.length}'); + print(' - Parameter groups: ${template.parameterGroups.length}'); + if (template.version != null) { + print(' - Version: ${template.version!.versionNumber}'); + } + print(''); + } on FirebaseRemoteConfigException catch (e) { + print('> Remote Config error: ${e.code} - ${e.message}'); + return; + } catch (e) { + print('> Error fetching template: $e'); + return; + } + + // Example 2: Validate a modified template without publishing. + try { + print('> Validating a modified template (no publish)...\n'); + final modified = RemoteConfigTemplate( + etag: template.etag, + conditions: template.conditions, + parameters: { + ...template.parameters, + 'dart_admin_sdk_demo': RemoteConfigParameter( + defaultValue: const ExplicitParameterValue(value: 'hello'), + description: 'Demo parameter from the Dart Admin SDK example.', + valueType: ParameterValueType.string, + ), + }, + parameterGroups: template.parameterGroups, + version: template.version, + ); + final validated = await remoteConfig.validateTemplate(modified); + print('Template validated!'); + print(' - ETag (restored): ${validated.etag}'); + print(''); + } on FirebaseRemoteConfigException catch (e) { + print('> Remote Config error: ${e.code} - ${e.message}'); + } catch (e) { + print('> Error validating template: $e'); + } + + // Example 3: List published versions. + try { + print('> Listing published versions (page size 5)...\n'); + final result = await remoteConfig.listVersions( + ListVersionsOptions(pageSize: 5), + ); + print('Got ${result.versions.length} version(s):'); + for (final v in result.versions) { + print(' - v${v.versionNumber}: ${v.description ?? '(no description)'}'); + } + print(''); + } on FirebaseRemoteConfigException catch (e) { + print('> Remote Config error: ${e.code} - ${e.message}'); + } catch (e) { + print('> Error listing versions: $e'); + } + + // Example 4: Server-side template evaluation. + try { + print('> Fetching server template and evaluating...\n'); + final serverTemplate = await remoteConfig.getServerTemplate( + defaultConfig: const { + 'enable_new_ui': false, + 'max_items': 50, + }, + ); + final config = serverTemplate.evaluate( + const EvaluationContext( + randomizationId: 'demo-user-id', + customSignals: { + 'app_version': '2.3.1', + 'country': 'US', + }, + ), + ); + print('Server config evaluated!'); + print(' - enable_new_ui: ${config.getBoolean('enable_new_ui')}'); + print(' - max_items: ${config.getInt('max_items')}'); + final all = config.getAll(); + print(' - total resolved keys: ${all.length}'); + print(''); + } on FirebaseRemoteConfigException catch (e) { + print('> Remote Config error: ${e.code} - ${e.message}'); + } catch (e) { + print('> Error evaluating server template: $e'); + } +} diff --git a/packages/firebase_admin_sdk/lib/remote_config.dart b/packages/firebase_admin_sdk/lib/remote_config.dart new file mode 100644 index 00000000..fa7b9998 --- /dev/null +++ b/packages/firebase_admin_sdk/lib/remote_config.dart @@ -0,0 +1,21 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'src/remote_config/remote_config.dart' + hide + ConditionEvaluator, + RemoteConfigHttpClient, + RemoteConfigHttpResult, + RemoteConfigRequestHandler, + remoteConfigErrorCodeMapping; diff --git a/packages/firebase_admin_sdk/lib/src/app.dart b/packages/firebase_admin_sdk/lib/src/app.dart index 11c330fc..93c4ca46 100644 --- a/packages/firebase_admin_sdk/lib/src/app.dart +++ b/packages/firebase_admin_sdk/lib/src/app.dart @@ -32,6 +32,7 @@ import '../auth.dart'; import '../firestore.dart'; import '../functions.dart'; import '../messaging.dart'; +import '../remote_config.dart'; import '../security_rules.dart'; import '../storage.dart'; import 'utils/utils.dart'; diff --git a/packages/firebase_admin_sdk/lib/src/app/firebase_app.dart b/packages/firebase_admin_sdk/lib/src/app/firebase_app.dart index ba72c017..6fe5de7f 100644 --- a/packages/firebase_admin_sdk/lib/src/app/firebase_app.dart +++ b/packages/firebase_admin_sdk/lib/src/app/firebase_app.dart @@ -229,6 +229,11 @@ class FirebaseApp { /// Returns a cached instance if one exists, otherwise creates a new one. Messaging messaging() => Messaging.internal(this); + /// Gets the Remote Config service instance for this app. + /// + /// Returns a cached instance if one exists, otherwise creates a new one. + RemoteConfig remoteConfig() => RemoteConfig.internal(this); + /// Gets the Security Rules service instance for this app. /// /// Returns a cached instance if one exists, otherwise creates a new one. diff --git a/packages/firebase_admin_sdk/lib/src/app/firebase_service.dart b/packages/firebase_admin_sdk/lib/src/app/firebase_service.dart index 42d7e5ee..62585be4 100644 --- a/packages/firebase_admin_sdk/lib/src/app/firebase_service.dart +++ b/packages/firebase_admin_sdk/lib/src/app/firebase_service.dart @@ -20,6 +20,7 @@ enum FirebaseServiceType { auth(name: 'auth'), firestore(name: 'firestore'), messaging(name: 'messaging'), + remoteConfig(name: 'remote-config'), securityRules(name: 'security-rules'), functions(name: 'functions'), storage(name: 'storage'); diff --git a/packages/firebase_admin_sdk/lib/src/remote_config/condition_evaluator.dart b/packages/firebase_admin_sdk/lib/src/remote_config/condition_evaluator.dart new file mode 100644 index 00000000..2af1e52b --- /dev/null +++ b/packages/firebase_admin_sdk/lib/src/remote_config/condition_evaluator.dart @@ -0,0 +1,281 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of 'remote_config.dart'; + +/// Server-side condition evaluator for [ServerTemplate.evaluate]. +@internal +class ConditionEvaluator { + static const int _maxRecursionDepth = 10; + + /// Evaluates each named condition in [namedConditions] against [context], + /// returning a map from condition name to boolean result. The returned map + /// preserves insertion order, matching the priority order of the input. + Map evaluateConditions( + List namedConditions, + EvaluationContext context, + ) { + final results = {}; + for (final namedCondition in namedConditions) { + results[namedCondition.name] = _evaluate( + namedCondition.condition, + context, + 0, + ); + } + return results; + } + + bool _evaluate( + OneOfCondition condition, + EvaluationContext context, + int nestingLevel, + ) { + if (nestingLevel >= _maxRecursionDepth) return false; + return switch (condition) { + TrueCondition() => true, + FalseCondition() => false, + OrCondition() => _evaluateOr(condition, context, nestingLevel + 1), + AndCondition() => _evaluateAnd(condition, context, nestingLevel + 1), + PercentCondition() => _evaluatePercent(condition, context), + CustomSignalCondition() => _evaluateCustomSignal(condition, context), + }; + } + + bool _evaluateOr( + OrCondition cond, + EvaluationContext context, + int nestingLevel, + ) { + final subs = cond.conditions ?? const []; + for (final sub in subs) { + if (_evaluate(sub, context, nestingLevel + 1)) return true; + } + return false; + } + + bool _evaluateAnd( + AndCondition cond, + EvaluationContext context, + int nestingLevel, + ) { + final subs = cond.conditions ?? const []; + for (final sub in subs) { + if (!_evaluate(sub, context, nestingLevel + 1)) return false; + } + return true; + } + + bool _evaluatePercent(PercentCondition cond, EvaluationContext context) { + final randomizationId = context.randomizationId; + if (randomizationId == null) return false; + + final op = cond.percentOperator; + if (op == null || op == PercentConditionOperator.unknown) return false; + + final microPercent = cond.microPercent ?? 0; + final upper = cond.microPercentRange?.microPercentUpperBound ?? 0; + final lower = cond.microPercentRange?.microPercentLowerBound ?? 0; + + final seed = cond.seed; + final seedPrefix = (seed != null && seed.isNotEmpty) ? '$seed.' : ''; + final stringToHash = '$seedPrefix$randomizationId'; + + final hash = _hashSeededRandomizationId(stringToHash); + final instanceMicroPercentile = (hash % BigInt.from(100 * 1000000)).toInt(); + + return switch (op) { + PercentConditionOperator.lessOrEqual => + instanceMicroPercentile <= microPercent, + PercentConditionOperator.greaterThan => + instanceMicroPercentile > microPercent, + PercentConditionOperator.between => + instanceMicroPercentile > lower && instanceMicroPercentile <= upper, + PercentConditionOperator.unknown => false, + }; + } + + /// SHA-256 the input string and return the resulting digest as a [BigInt]. + /// Visible for testing. + @internal + static BigInt hashSeededRandomizationIdForTest( + String seededRandomizationId, + ) => _hashSeededRandomizationId(seededRandomizationId); + + static BigInt _hashSeededRandomizationId(String input) { + final bytes = sha256.convert(utf8.encode(input)).bytes; + final hex = StringBuffer(); + for (final b in bytes) { + hex.write(b.toRadixString(16).padLeft(2, '0')); + } + return BigInt.parse(hex.toString(), radix: 16); + } + + bool _evaluateCustomSignal( + CustomSignalCondition cond, + EvaluationContext context, + ) { + final op = cond.customSignalOperator; + final key = cond.customSignalKey; + final targets = cond.targetCustomSignalValues; + + if (op == null || + op == CustomSignalOperator.unknown || + key == null || + targets == null || + targets.isEmpty) { + return false; + } + + final actual = context.customSignals[key]; + if (actual == null) return false; + + return switch (op) { + CustomSignalOperator.stringContains => _compareStrings( + targets, + actual, + (target, actualString) => actualString.contains(target), + ), + CustomSignalOperator.stringDoesNotContain => !_compareStrings( + targets, + actual, + (target, actualString) => actualString.contains(target), + ), + CustomSignalOperator.stringExactlyMatches => _compareStrings( + targets, + actual, + (target, actualString) => actualString.trim() == target.trim(), + ), + CustomSignalOperator.stringContainsRegex => _compareStrings( + targets, + actual, + (target, actualString) => RegExp(target).hasMatch(actualString), + ), + CustomSignalOperator.numericLessThan => _compareNumbers( + actual, + targets[0], + (r) => r < 0, + ), + CustomSignalOperator.numericLessEqual => _compareNumbers( + actual, + targets[0], + (r) => r <= 0, + ), + CustomSignalOperator.numericEqual => _compareNumbers( + actual, + targets[0], + (r) => r == 0, + ), + CustomSignalOperator.numericNotEqual => _compareNumbers( + actual, + targets[0], + (r) => r != 0, + ), + CustomSignalOperator.numericGreaterThan => _compareNumbers( + actual, + targets[0], + (r) => r > 0, + ), + CustomSignalOperator.numericGreaterEqual => _compareNumbers( + actual, + targets[0], + (r) => r >= 0, + ), + CustomSignalOperator.semanticVersionLessThan => _compareSemver( + actual, + targets[0], + (r) => r < 0, + ), + CustomSignalOperator.semanticVersionLessEqual => _compareSemver( + actual, + targets[0], + (r) => r <= 0, + ), + CustomSignalOperator.semanticVersionEqual => _compareSemver( + actual, + targets[0], + (r) => r == 0, + ), + CustomSignalOperator.semanticVersionNotEqual => _compareSemver( + actual, + targets[0], + (r) => r != 0, + ), + CustomSignalOperator.semanticVersionGreaterThan => _compareSemver( + actual, + targets[0], + (r) => r > 0, + ), + CustomSignalOperator.semanticVersionGreaterEqual => _compareSemver( + actual, + targets[0], + (r) => r >= 0, + ), + CustomSignalOperator.unknown => false, + }; + } +} + +bool _compareStrings( + List targets, + Object actualValue, + bool Function(String target, String actual) predicate, +) { + final actualString = actualValue.toString(); + return targets.any((target) => predicate(target, actualString)); +} + +bool _compareNumbers( + Object actualValue, + String targetValue, + bool Function(int result) predicate, +) { + final actual = _coerceDouble(actualValue); + final target = double.tryParse(targetValue); + if (actual == null || target == null) return false; + final cmp = actual < target ? -1 : (actual > target ? 1 : 0); + return predicate(cmp); +} + +const int _semverMaxLength = 5; + +bool _compareSemver( + Object actualValue, + String targetValue, + bool Function(int result) predicate, +) { + final v1 = actualValue.toString().split('.'); + final v2 = targetValue.split('.'); + + if (v1.length > _semverMaxLength || v2.length > _semverMaxLength) { + return false; + } + + for (var i = 0; i < _semverMaxLength; i++) { + // Semver segments are integers per spec; non-integer parts (e.g. "1.0-rc") + // make the segment unparsable and the whole comparison return false. + final s1 = i < v1.length ? int.tryParse(v1[i]) : 0; + final s2 = i < v2.length ? int.tryParse(v2[i]) : 0; + if (s1 == null || s2 == null) return false; + if (s1 < s2) return predicate(-1); + if (s1 > s2) return predicate(1); + } + return predicate(0); +} + +double? _coerceDouble(Object value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} diff --git a/packages/firebase_admin_sdk/lib/src/remote_config/remote_config.dart b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config.dart new file mode 100644 index 00000000..9734e2c0 --- /dev/null +++ b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config.dart @@ -0,0 +1,302 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:googleapis_auth/auth_io.dart' as googleapis_auth; +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +import '../app.dart'; + +part 'condition_evaluator.dart'; +part 'remote_config_api.dart'; +part 'remote_config_exception.dart'; +part 'remote_config_http_client.dart'; +part 'remote_config_request_handler.dart'; + +/// Manages Firebase Remote Config templates and evaluates server-side +/// configuration. +/// +/// Access via [FirebaseApp.remoteConfig]. +class RemoteConfig implements FirebaseService { + /// Creates or returns the cached Remote Config instance for the given app. + @internal + factory RemoteConfig.internal( + FirebaseApp app, { + RemoteConfigRequestHandler? requestHandler, + }) { + return app.getOrInitService( + FirebaseServiceType.remoteConfig.name, + (app) => RemoteConfig._(app, requestHandler: requestHandler), + ); + } + + RemoteConfig._(this.app, {RemoteConfigRequestHandler? requestHandler}) + : _requestHandler = requestHandler ?? RemoteConfigRequestHandler(app); + + @override + final FirebaseApp app; + + final RemoteConfigRequestHandler _requestHandler; + + /// Returns a Remote Config template. + /// + /// With no argument, returns the current active version. Passing + /// [versionNumber] (an integer in int64 format) returns that specific + /// historical version. + Future getTemplate([String? versionNumber]) { + return _requestHandler.getTemplate(versionNumber); + } + + /// Validates a Remote Config template against the server. Returns the + /// validated template (with the original etag). + Future validateTemplate( + RemoteConfigTemplate template, + ) => _requestHandler.validateTemplate(template); + + /// Publishes a Remote Config template. + /// + /// If [force] is true, the request bypasses the etag check (sends `If-Match: *`). + /// Bypassing the etag risks losing concurrent updates and is not recommended. + Future publishTemplate( + RemoteConfigTemplate template, { + bool force = false, + }) => _requestHandler.publishTemplate(template, force: force); + + /// Rolls back the published template to the specified [versionNumber]. + /// + /// [versionNumber] must be lower than the current version and not deleted + /// due to staleness. Equivalent to publishing a previously published template + /// with `force: true`. + Future rollback(String versionNumber) { + return _requestHandler.rollback(versionNumber); + } + + /// Lists published template versions in reverse chronological order. + Future listVersions([ListVersionsOptions? options]) { + return _requestHandler.listVersions(options); + } + + /// Builds a [RemoteConfigTemplate] from a JSON string. Throws + /// [FirebaseRemoteConfigException] with code `invalid-argument` on parse + /// failure or missing required fields. + RemoteConfigTemplate createTemplateFromJson(String json) { + if (json.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'JSON string must be a non-empty string.', + ); + } + Object? decoded; + try { + decoded = jsonDecode(json); + } catch (e) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Failed to parse the JSON string: $json. $e', + ); + } + if (decoded is! Map) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'JSON must decode to an object, got ${decoded.runtimeType}.', + ); + } + return RemoteConfigTemplate.fromJson(decoded); + } + + /// Fetches and caches the current active server template, returning a + /// [ServerTemplate] ready for [ServerTemplate.evaluate]. + Future getServerTemplate({ + Map? defaultConfig, + }) async { + final template = initServerTemplate(defaultConfig: defaultConfig); + await template.load(); + return template; + } + + /// Synchronously builds a [ServerTemplate] without fetching. The caller can + /// pre-load the template via [ServerTemplate.set] or trigger a fetch via + /// [ServerTemplate.load]. + /// + /// [template] may be a [ServerTemplateData] or a JSON string in + /// [ServerTemplateData] shape. + ServerTemplate initServerTemplate({ + Map? defaultConfig, + Object? template, + }) { + final result = ServerTemplate._( + _requestHandler, + ConditionEvaluator(), + defaultConfig ?? const {}, + ); + if (template != null) { + result.set(template); + } + return result; + } + + @override + Future delete() async { + // No cleanup needed. + } +} + +/// Stateful wrapper around a server-side Remote Config template, with built-in +/// caching and in-process evaluation against an [EvaluationContext]. +/// +/// Obtain instances via [RemoteConfig.getServerTemplate] (asynchronous fetch) +/// or [RemoteConfig.initServerTemplate] (synchronous, no fetch). +class ServerTemplate { + ServerTemplate._( + this._requestHandler, + this._evaluator, + Map defaultConfig, + ) : defaultConfig = Map.unmodifiable(defaultConfig), + _stringifiedDefaultConfig = { + for (final entry in defaultConfig.entries) entry.key: '${entry.value}', + }; + + final RemoteConfigRequestHandler _requestHandler; + final ConditionEvaluator _evaluator; + + /// In-app default values used by [evaluate] for keys missing from the + /// remote template. Values must be `String`, `num`, or `bool`. + final Map defaultConfig; + + final Map _stringifiedDefaultConfig; + ServerTemplateData? _cache; + + /// Fetches the current active server template and caches it locally. + Future load() async { + _cache = await _requestHandler.getServerTemplate(); + } + + /// Replaces the cached template. [data] may be a [ServerTemplateData] or a + /// JSON string in [ServerTemplateData] shape. + void set(Object data) { + if (data is ServerTemplateData) { + _cache = data; + return; + } + if (data is String) { + Object? parsed; + try { + parsed = jsonDecode(data); + } catch (e) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Failed to parse the JSON string: $data. $e', + ); + } + if (parsed is! Map) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'JSON must decode to an object, got ${parsed.runtimeType}.', + ); + } + _cache = ServerTemplateData.fromJson(parsed); + return; + } + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Expected ServerTemplateData or String, got ${data.runtimeType}.', + ); + } + + /// Evaluates the cached template against [context] and returns a + /// [ServerConfig]. + /// + /// Throws [FirebaseRemoteConfigException] with code `failed-precondition` + /// if no template is cached. Call [load] or [set] first. + ServerConfig evaluate([EvaluationContext? context]) { + final template = _cache; + if (template == null) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.failedPrecondition, + 'No Remote Config Server template in cache. ' + 'Call load() before calling evaluate().', + ); + } + + final ctx = context ?? const EvaluationContext(); + final conditionResults = _evaluator.evaluateConditions( + template.conditions, + ctx, + ); + + // 1. Seed config values with stringified default config. + final configValues = { + for (final entry in _stringifiedDefaultConfig.entries) + entry.key: Value._(ValueSource.valueDefault, entry.value), + }; + + // 2. Overlay parameter values from the evaluated template. + for (final entry in template.parameters.entries) { + final key = entry.key; + final parameter = entry.value; + final conditionalValues = + parameter.conditionalValues ?? + const {}; + + RemoteConfigParameterValue? selected; + // Iterate evaluated conditions in template order; first match wins. + for (final result in conditionResults.entries) { + final conditionalValue = conditionalValues[result.key]; + if (conditionalValue != null && result.value) { + selected = conditionalValue; + break; + } + } + + if (selected is InAppDefaultValue) { + // Use whatever was already in defaultConfig; do not override. + continue; + } + if (selected is ExplicitParameterValue) { + configValues[key] = Value._(ValueSource.valueRemote, selected.value); + continue; + } + + // No matching conditional value — fall back to the parameter default. + final defaultValue = parameter.defaultValue; + if (defaultValue is InAppDefaultValue) continue; + if (defaultValue is ExplicitParameterValue) { + configValues[key] = Value._( + ValueSource.valueRemote, + defaultValue.value, + ); + } + } + + return ServerConfig.internal(configValues); + } + + /// Returns the cached [ServerTemplateData], or throws if no template has + /// been loaded. + ServerTemplateData toJson() { + final cache = _cache; + if (cache == null) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.failedPrecondition, + 'No Remote Config Server template in cache. ' + 'Call load() or set() before calling toJson().', + ); + } + return cache; + } +} diff --git a/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_api.dart b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_api.dart new file mode 100644 index 00000000..add2fb15 --- /dev/null +++ b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_api.dart @@ -0,0 +1,1350 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of 'remote_config.dart'; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/// Display tag color for a [RemoteConfigCondition] in the Firebase Console. +enum TagColor { + blue('BLUE'), + brown('BROWN'), + cyan('CYAN'), + deepOrange('DEEP_ORANGE'), + green('GREEN'), + indigo('INDIGO'), + lime('LIME'), + orange('ORANGE'), + pink('PINK'), + purple('PURPLE'), + teal('TEAL'); + + const TagColor(this.name); + + final String name; + + static TagColor? fromName(String? value) { + if (value == null) return null; + for (final color in TagColor.values) { + if (color.name == value) return color; + } + return null; + } +} + +/// Data type of a Remote Config parameter value. Defaults to [string] if +/// unspecified. +enum ParameterValueType { + string('STRING'), + boolean('BOOLEAN'), + number('NUMBER'), + json('JSON'); + + const ParameterValueType(this.name); + + final String name; + + static ParameterValueType? fromName(String? value) { + if (value == null) return null; + for (final t in ParameterValueType.values) { + if (t.name == value) return t; + } + return null; + } +} + +/// Comparison operators for [PercentCondition]. +enum PercentConditionOperator { + unknown('UNKNOWN'), + lessOrEqual('LESS_OR_EQUAL'), + greaterThan('GREATER_THAN'), + between('BETWEEN'); + + const PercentConditionOperator(this.name); + + final String name; + + static PercentConditionOperator fromName(String? value) { + if (value == null) return PercentConditionOperator.unknown; + for (final op in PercentConditionOperator.values) { + if (op.name == value) return op; + } + return PercentConditionOperator.unknown; + } +} + +/// Comparison operators for [CustomSignalCondition]. +enum CustomSignalOperator { + unknown('UNKNOWN'), + numericLessThan('NUMERIC_LESS_THAN'), + numericLessEqual('NUMERIC_LESS_EQUAL'), + numericEqual('NUMERIC_EQUAL'), + numericNotEqual('NUMERIC_NOT_EQUAL'), + numericGreaterThan('NUMERIC_GREATER_THAN'), + numericGreaterEqual('NUMERIC_GREATER_EQUAL'), + stringContains('STRING_CONTAINS'), + stringDoesNotContain('STRING_DOES_NOT_CONTAIN'), + stringExactlyMatches('STRING_EXACTLY_MATCHES'), + stringContainsRegex('STRING_CONTAINS_REGEX'), + semanticVersionLessThan('SEMANTIC_VERSION_LESS_THAN'), + semanticVersionLessEqual('SEMANTIC_VERSION_LESS_EQUAL'), + semanticVersionEqual('SEMANTIC_VERSION_EQUAL'), + semanticVersionNotEqual('SEMANTIC_VERSION_NOT_EQUAL'), + semanticVersionGreaterThan('SEMANTIC_VERSION_GREATER_THAN'), + semanticVersionGreaterEqual('SEMANTIC_VERSION_GREATER_EQUAL'); + + const CustomSignalOperator(this.name); + + final String name; + + static CustomSignalOperator fromName(String? value) { + if (value == null) return CustomSignalOperator.unknown; + for (final op in CustomSignalOperator.values) { + if (op.name == value) return op; + } + return CustomSignalOperator.unknown; + } +} + +/// Source of a value returned by [ServerConfig]. +enum ValueSource { + /// The value was defined by a static constant. Returned by [ServerConfig] + /// for keys with no default and no remote value. + valueStatic('static'), + + /// The value came from the in-app default config provided to + /// [RemoteConfig.initServerTemplate] or [RemoteConfig.getServerTemplate]. + valueDefault('default'), + + /// The value came from evaluating a server template parameter. + valueRemote('remote'); + + const ValueSource(this.name); + + /// The on-the-wire representation. + final String name; +} + +// --------------------------------------------------------------------------- +// Helpers (private) +// --------------------------------------------------------------------------- + +Map _decodeMap( + Object? raw, + T Function(Map) decode, +) { + if (raw == null) return {}; + if (raw is! Map) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Expected an object, got ${raw.runtimeType}.', + ); + } + final result = {}; + raw.forEach((key, value) { + result[key as String] = decode(value as Map); + }); + return result; +} + +Map _encodeMap( + Map map, + Map Function(T) encode, +) { + return { + for (final entry in map.entries) entry.key: encode(entry.value), + }; +} + +List _decodeList(Object? raw, T Function(Map) decode) { + if (raw == null) return []; + if (raw is! List) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Expected an array, got ${raw.runtimeType}.', + ); + } + return [for (final item in raw) decode(item as Map)]; +} + +// --------------------------------------------------------------------------- +// Simple value types +// --------------------------------------------------------------------------- + +/// User who performed an update on a Remote Config template (output only). +class RemoteConfigUser { + const RemoteConfigUser({required this.email, this.name, this.imageUrl}); + + factory RemoteConfigUser.fromJson(Map json) { + return RemoteConfigUser( + email: (json['email'] as String?) ?? '', + name: json['name'] as String?, + imageUrl: json['imageUrl'] as String?, + ); + } + + /// Email address. Output only. + final String email; + + /// Display name. Output only. + final String? name; + + /// Image URL. Output only. + final String? imageUrl; + + Map toJson() { + return { + 'email': email, + 'name': ?name, + 'imageUrl': ?imageUrl, + }; + } +} + +/// Inclusive upper / exclusive lower bound for [PercentConditionOperator.between]. +class MicroPercentRange { + const MicroPercentRange({ + this.microPercentLowerBound, + this.microPercentUpperBound, + }); + + factory MicroPercentRange.fromJson(Map json) { + return MicroPercentRange( + microPercentLowerBound: (json['microPercentLowerBound'] as num?)?.toInt(), + microPercentUpperBound: (json['microPercentUpperBound'] as num?)?.toInt(), + ); + } + + /// Lower bound in micro-percents, range `[0, 100_000_000]`. + /// + /// The bound is **exclusive** at evaluation time: a percentile equal to + /// this value does not match. + final int? microPercentLowerBound; + + /// Upper bound in micro-percents, range `[0, 100_000_000]`. + /// + /// The bound is **inclusive** at evaluation time: a percentile equal to + /// this value matches. + final int? microPercentUpperBound; + + Map toJson() { + return { + 'microPercentLowerBound': ?microPercentLowerBound, + 'microPercentUpperBound': ?microPercentUpperBound, + }; + } +} + +/// Value linked to a [Rollout](https://firebase.google.com/docs/remote-config/parameters). +class RolloutValue { + const RolloutValue({ + required this.rolloutId, + required this.value, + required this.percent, + }); + + factory RolloutValue.fromJson(Map json) { + return RolloutValue( + rolloutId: (json['rolloutId'] as String?) ?? '', + value: (json['value'] as String?) ?? '', + percent: (json['percent'] as num?)?.toDouble() ?? 0, + ); + } + + /// The identifier that associates this parameter value with a Rollout. + final String rolloutId; + + /// The user-specified value to be rolled out. + final String value; + + /// Percentage of users that will receive the rollout value, in the range + /// `[0, 100]`. Stored as a `double` because the REST API allows fractional + /// percentages. + final double percent; + + Map toJson() { + return { + 'rolloutId': rolloutId, + 'value': value, + 'percent': percent, + }; + } +} + +/// Personalization-derived parameter value reference. +class PersonalizationValue { + const PersonalizationValue({required this.personalizationId}); + + factory PersonalizationValue.fromJson(Map json) { + return PersonalizationValue( + personalizationId: (json['personalizationId'] as String?) ?? '', + ); + } + + /// Identifier that represents a personalization definition. The definition + /// is used to resolve the value at config fetch time. This is system + /// generated and should not be modified. + final String personalizationId; + + Map toJson() { + return {'personalizationId': personalizationId}; + } +} + +/// Sealed type representing a single experiment variant value. +sealed class ExperimentVariantValue { + const ExperimentVariantValue._({required this.variantId}); + + factory ExperimentVariantValue.fromJson(Map json) { + if (json['noChange'] == true) { + return ExperimentVariantNoChange( + variantId: (json['variantId'] as String?) ?? '', + ); + } + return ExperimentVariantExplicitValue( + variantId: (json['variantId'] as String?) ?? '', + value: (json['value'] as String?) ?? '', + ); + } + + /// The ID of this variant within the experiment. + final String variantId; + + Map toJson(); +} + +/// Experiment variant with an explicit string value. +class ExperimentVariantExplicitValue extends ExperimentVariantValue { + const ExperimentVariantExplicitValue({ + required super.variantId, + required this.value, + }) : super._(); + + /// The variant's value within the experiment. + final String value; + + @override + Map toJson() { + return {'variantId': variantId, 'value': value}; + } +} + +/// Experiment variant marked as "no change" — the SDK falls through to the +/// next matching condition or default value. +class ExperimentVariantNoChange extends ExperimentVariantValue { + const ExperimentVariantNoChange({required super.variantId}) : super._(); + + @override + Map toJson() { + return {'variantId': variantId, 'noChange': true}; + } +} + +/// Experiment-derived parameter value. +class ExperimentValue { + const ExperimentValue({ + required this.experimentId, + required this.variantValue, + this.exposurePercent, + }); + + factory ExperimentValue.fromJson(Map json) { + return ExperimentValue( + experimentId: (json['experimentId'] as String?) ?? '', + variantValue: _decodeList( + json['variantValue'], + ExperimentVariantValue.fromJson, + ), + exposurePercent: (json['exposurePercent'] as num?)?.toDouble(), + ); + } + + /// The identifier that associates this parameter value with an Experiment + /// in Firebase A/B Testing. + final String experimentId; + + /// Variants served by this experiment. + final List variantValue; + + /// Server-side exposure evaluation: the fraction of users exposed to the + /// experiment, in the range `[0, 100]` inclusive. `null` if the server did + /// not include this field. + final double? exposurePercent; + + Map toJson() { + return { + 'experimentId': experimentId, + 'variantValue': [for (final v in variantValue) v.toJson()], + 'exposurePercent': ?exposurePercent, + }; + } +} + +// --------------------------------------------------------------------------- +// Parameter values +// --------------------------------------------------------------------------- + +/// Sealed type for a Remote Config parameter value. +/// +/// One of: [ExplicitParameterValue], [InAppDefaultValue], +/// [RolloutParameterValue], [PersonalizationParameterValue], +/// [ExperimentParameterValue]. +sealed class RemoteConfigParameterValue { + const RemoteConfigParameterValue._(); + + Map toJson(); + + factory RemoteConfigParameterValue.fromJson(Map json) { + if (json.containsKey('useInAppDefault')) { + return InAppDefaultValue( + useInAppDefault: (json['useInAppDefault'] as bool?) ?? true, + ); + } + if (json.containsKey('rolloutValue')) { + return RolloutParameterValue( + rolloutValue: RolloutValue.fromJson( + json['rolloutValue']! as Map, + ), + ); + } + if (json.containsKey('personalizationValue')) { + return PersonalizationParameterValue( + personalizationValue: PersonalizationValue.fromJson( + json['personalizationValue']! as Map, + ), + ); + } + if (json.containsKey('experimentValue')) { + return ExperimentParameterValue( + experimentValue: ExperimentValue.fromJson( + json['experimentValue']! as Map, + ), + ); + } + if (json.containsKey('value')) { + return ExplicitParameterValue(value: (json['value'] as String?) ?? ''); + } + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Unknown parameter value shape: ${json.keys.toList()}', + ); + } +} + +/// Parameter value with an explicit string value. +class ExplicitParameterValue extends RemoteConfigParameterValue { + const ExplicitParameterValue({required this.value}) : super._(); + + /// The string value the parameter is set to. + final String value; + + @override + Map toJson() => {'value': value}; +} + +/// Parameter value indicating the client should use its in-app default. +class InAppDefaultValue extends RemoteConfigParameterValue { + const InAppDefaultValue({required this.useInAppDefault}) : super._(); + + /// When `true`, the parameter is omitted from the server-evaluated config. + final bool useInAppDefault; + + @override + Map toJson() { + return {'useInAppDefault': useInAppDefault}; + } +} + +/// Parameter value linked to a rollout. +class RolloutParameterValue extends RemoteConfigParameterValue { + const RolloutParameterValue({required this.rolloutValue}) : super._(); + + /// The rollout descriptor. + final RolloutValue rolloutValue; + + @override + Map toJson() { + return {'rolloutValue': rolloutValue.toJson()}; + } +} + +/// Parameter value linked to a personalization. +class PersonalizationParameterValue extends RemoteConfigParameterValue { + const PersonalizationParameterValue({required this.personalizationValue}) + : super._(); + + /// The personalization descriptor. + final PersonalizationValue personalizationValue; + + @override + Map toJson() { + return { + 'personalizationValue': personalizationValue.toJson(), + }; + } +} + +/// Parameter value linked to an experiment. +class ExperimentParameterValue extends RemoteConfigParameterValue { + const ExperimentParameterValue({required this.experimentValue}) : super._(); + + /// The experiment descriptor. + final ExperimentValue experimentValue; + + @override + Map toJson() { + return {'experimentValue': experimentValue.toJson()}; + } +} + +// --------------------------------------------------------------------------- +// Parameters and groups +// --------------------------------------------------------------------------- + +/// A Remote Config parameter. +/// +/// At minimum, a [defaultValue] or [conditionalValues] entry must be present +/// for the parameter to have any effect. +class RemoteConfigParameter { + RemoteConfigParameter({ + this.defaultValue, + Map? conditionalValues, + this.description, + this.valueType, + }) : conditionalValues = conditionalValues == null + ? null + : Map.unmodifiable( + conditionalValues, + ); + + factory RemoteConfigParameter.fromJson(Map json) { + final defaultValueJson = json['defaultValue']; + final conditionalValuesJson = json['conditionalValues']; + return RemoteConfigParameter( + defaultValue: defaultValueJson == null + ? null + : RemoteConfigParameterValue.fromJson( + defaultValueJson as Map, + ), + conditionalValues: conditionalValuesJson == null + ? null + : _decodeMap( + conditionalValuesJson, + RemoteConfigParameterValue.fromJson, + ), + description: json['description'] as String?, + valueType: ParameterValueType.fromName(json['valueType'] as String?), + ); + } + + /// Value used when no condition matches. + final RemoteConfigParameterValue? defaultValue; + + /// Map from condition name to value. Conditions are evaluated in template + /// order; the first match wins. + final Map? conditionalValues; + + /// Optional human-readable description for this parameter (≤256 unicode + /// characters). + final String? description; + + /// Data type for all values of this parameter in the current version of + /// the template. Defaults to [ParameterValueType.string] when unspecified. + final ParameterValueType? valueType; + + Map toJson() { + return { + 'defaultValue': ?defaultValue?.toJson(), + if (conditionalValues != null) + 'conditionalValues': _encodeMap(conditionalValues!, (v) => v.toJson()), + 'description': ?description, + 'valueType': ?valueType?.name, + }; + } +} + +/// A logical grouping of Remote Config parameters (management-only; does not +/// affect client-side fetches). +class RemoteConfigParameterGroup { + RemoteConfigParameterGroup({ + this.description, + Map? parameters, + }) : parameters = Map.unmodifiable( + parameters ?? const {}, + ); + + factory RemoteConfigParameterGroup.fromJson(Map json) { + return RemoteConfigParameterGroup( + description: json['description'] as String?, + parameters: _decodeMap( + json['parameters'], + RemoteConfigParameter.fromJson, + ), + ); + } + + /// Optional human-readable description (≤256 unicode characters). + final String? description; + + /// Parameters that belong to this group. + final Map parameters; + + Map toJson() { + return { + 'description': ?description, + 'parameters': _encodeMap(parameters, (p) => p.toJson()), + }; + } +} + +// --------------------------------------------------------------------------- +// Conditions +// --------------------------------------------------------------------------- + +/// A client-side condition used by [RemoteConfigTemplate]. The expression is +/// a free-form string evaluated by the Remote Config client SDK. +/// +/// Server-side templates use [NamedCondition] instead. +class RemoteConfigCondition { + const RemoteConfigCondition({ + required this.name, + required this.expression, + this.tagColor, + }); + + factory RemoteConfigCondition.fromJson(Map json) { + return RemoteConfigCondition( + name: (json['name'] as String?) ?? '', + expression: (json['expression'] as String?) ?? '', + tagColor: TagColor.fromName(json['tagColor'] as String?), + ); + } + + /// Required. Non-empty and unique name of this condition. + final String name; + + /// Condition expression syntax — see the + /// [Condition reference](https://firebase.google.com/docs/remote-config/condition-reference). + final String expression; + + /// Optional display color for the Firebase Console. + final TagColor? tagColor; + + Map toJson() { + return { + 'name': name, + 'expression': expression, + 'tagColor': ?tagColor?.name, + }; + } +} + +/// A server-side named condition used by [ServerTemplateData]. +/// +/// Unlike [RemoteConfigCondition], the [condition] tree is structured and is +/// evaluated in-process by the Admin SDK. +class NamedCondition { + const NamedCondition({required this.name, required this.condition}); + + factory NamedCondition.fromJson(Map json) { + return NamedCondition( + name: (json['name'] as String?) ?? '', + condition: OneOfCondition.fromJson( + (json['condition'] as Map?) ?? + const {}, + ), + ); + } + + /// Required. Non-empty and unique name of this condition. + final String name; + + /// The structured condition tree. + final OneOfCondition condition; +} + +/// Sealed type for the structured server-side condition tree. +sealed class OneOfCondition { + const OneOfCondition._(); + + Map toJson(); + + factory OneOfCondition.fromJson(Map json) { + final or = json['orCondition']; + if (or is Map) return OrCondition.fromJson(or); + final and = json['andCondition']; + if (and is Map) return AndCondition.fromJson(and); + if (json.containsKey('true')) return const TrueCondition(); + if (json.containsKey('false')) return const FalseCondition(); + final percent = json['percent']; + if (percent is Map) { + return PercentCondition.fromJson(percent); + } + final custom = json['customSignal']; + if (custom is Map) { + return CustomSignalCondition.fromJson(custom); + } + // Forward compat: unknown shape → constant false at evaluation time. + return const FalseCondition(); + } +} + +/// `OR` collection of conditions; true if any sub-condition is true. +class OrCondition extends OneOfCondition { + const OrCondition({this.conditions}) : super._(); + + factory OrCondition.fromJson(Map json) { + final raw = json['conditions']; + if (raw == null) return const OrCondition(); + if (raw is! List) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'OrCondition.conditions must be an array.', + ); + } + return OrCondition( + conditions: [ + for (final item in raw) + OneOfCondition.fromJson(item as Map), + ], + ); + } + + /// Sub-conditions; null or empty evaluates to false. + final List? conditions; + + @override + Map toJson() { + return { + 'orCondition': { + if (conditions != null) + 'conditions': [for (final c in conditions!) c.toJson()], + }, + }; + } +} + +/// `AND` collection of conditions; true only if all sub-conditions are true. +class AndCondition extends OneOfCondition { + const AndCondition({this.conditions}) : super._(); + + factory AndCondition.fromJson(Map json) { + final raw = json['conditions']; + if (raw == null) return const AndCondition(); + if (raw is! List) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'AndCondition.conditions must be an array.', + ); + } + return AndCondition( + conditions: [ + for (final item in raw) + OneOfCondition.fromJson(item as Map), + ], + ); + } + + /// Sub-conditions; null or empty evaluates to true. + final List? conditions; + + @override + Map toJson() { + return { + 'andCondition': { + if (conditions != null) + 'conditions': [for (final c in conditions!) c.toJson()], + }, + }; + } +} + +/// Always-true condition. +/// +/// Not declared in the public Firebase Remote Config v1 discovery document +/// but accepted by the runtime wire format on the `firebase-server` +/// namespace, so [OneOfCondition.fromJson] decodes it and the evaluator +/// returns `true` without recursing. +class TrueCondition extends OneOfCondition { + const TrueCondition() : super._(); + + @override + Map toJson() { + return {'true': {}}; + } +} + +/// Always-false condition. +/// +/// Not declared in the public Firebase Remote Config v1 discovery document; +/// see [TrueCondition] for the same caveat. +class FalseCondition extends OneOfCondition { + const FalseCondition() : super._(); + + @override + Map toJson() { + return {'false': {}}; + } +} + +/// Targets a percentile range of an instance's pseudo-random hash. +class PercentCondition extends OneOfCondition { + PercentCondition({ + this.percentOperator, + this.microPercent, + this.seed, + this.microPercentRange, + }) : super._() { + final microPercent = this.microPercent; + if (microPercent != null && + (microPercent < 0 || microPercent > 100000000)) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'microPercent must be in [0, 100000000], got $microPercent.', + ); + } + if (seed != null && seed!.length > 32) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'seed must be 0-32 characters, got ${seed!.length}.', + ); + } + } + + factory PercentCondition.fromJson(Map json) { + final rangeJson = json['microPercentRange']; + return PercentCondition( + percentOperator: PercentConditionOperator.fromName( + json['percentOperator'] as String?, + ), + microPercent: (json['microPercent'] as num?)?.toInt(), + seed: json['seed'] as String?, + microPercentRange: rangeJson is Map + ? MicroPercentRange.fromJson(rangeJson) + : null, + ); + } + + /// Operator to use; null is treated as [PercentConditionOperator.unknown]. + final PercentConditionOperator? percentOperator; + + /// Target percentile in micro-percents for `LESS_OR_EQUAL` / `GREATER_THAN`. + final int? microPercent; + + /// Optional seed (0-32 ASCII chars `[-_.0-9a-zA-Z]`). + final String? seed; + + /// Range for [PercentConditionOperator.between]. + final MicroPercentRange? microPercentRange; + + @override + Map toJson() { + return { + 'percent': { + 'percentOperator': ?percentOperator?.name, + 'microPercent': ?microPercent, + 'seed': ?seed, + 'microPercentRange': ?microPercentRange?.toJson(), + }, + }; + } +} + +/// Compares a custom signal from the [EvaluationContext] against target value(s). +class CustomSignalCondition extends OneOfCondition { + CustomSignalCondition({ + this.customSignalOperator, + this.customSignalKey, + List? targetCustomSignalValues, + }) : targetCustomSignalValues = targetCustomSignalValues == null + ? null + : List.unmodifiable(targetCustomSignalValues), + super._() { + if (targetCustomSignalValues != null) { + if (targetCustomSignalValues.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'targetCustomSignalValues must contain at least one value.', + ); + } + if (targetCustomSignalValues.length > 100) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'targetCustomSignalValues must contain at most 100 values, ' + 'got ${targetCustomSignalValues.length}.', + ); + } + } + } + + factory CustomSignalCondition.fromJson(Map json) { + final values = json['targetCustomSignalValues']; + return CustomSignalCondition( + customSignalOperator: CustomSignalOperator.fromName( + json['customSignalOperator'] as String?, + ), + customSignalKey: json['customSignalKey'] as String?, + targetCustomSignalValues: values is List + ? [for (final v in values) v as String] + : null, + ); + } + + /// Required. Operator used to compare the actual value against + /// [targetCustomSignalValues]. Null is treated as + /// [CustomSignalOperator.unknown] and evaluates to false. + final CustomSignalOperator? customSignalOperator; + + /// Required. The custom signal name to look up in + /// [EvaluationContext.customSignals]. + final String? customSignalKey; + + /// Required. 1–100 target values. Numeric and semantic-version operators + /// must receive exactly one value; string operators may receive multiple. + final List? targetCustomSignalValues; + + @override + Map toJson() { + return { + 'customSignal': { + 'customSignalOperator': ?customSignalOperator?.name, + 'customSignalKey': ?customSignalKey, + 'targetCustomSignalValues': ?targetCustomSignalValues, + }, + }; + } +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +/// A Remote Config template (client-style) returned by [RemoteConfig.getTemplate] +/// and friends. +class RemoteConfigTemplate { + RemoteConfigTemplate({ + List? conditions, + Map? parameters, + Map? parameterGroups, + required this.etag, + this.version, + }) : conditions = List.unmodifiable( + conditions ?? const [], + ), + parameters = Map.unmodifiable( + parameters ?? const {}, + ), + parameterGroups = Map.unmodifiable( + parameterGroups ?? const {}, + ); + + factory RemoteConfigTemplate.fromJson(Map json) { + final etag = json['etag'] as String?; + if (etag == null || etag.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Invalid Remote Config template: missing or empty etag.', + ); + } + final versionJson = json['version']; + return RemoteConfigTemplate( + conditions: switch (json['conditions']) { + null => const [], + final List list => [ + for (final item in list) + RemoteConfigCondition.fromJson(item as Map), + ], + _ => throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Remote Config conditions must be an array.', + ), + }, + parameters: _decodeMap( + json['parameters'], + RemoteConfigParameter.fromJson, + ), + parameterGroups: _decodeMap( + json['parameterGroups'], + RemoteConfigParameterGroup.fromJson, + ), + etag: etag, + version: versionJson is Map + ? Version.fromJson(versionJson) + : null, + ); + } + + /// Conditions in priority (descending) order. + final List conditions; + + /// Parameter map keyed by parameter name. + final Map parameters; + + /// Parameter group map keyed by group name. + final Map parameterGroups; + + /// ETag of the current template (read-only). + final String etag; + + /// Optional version metadata. + final Version? version; + + Map toJson() { + return { + 'conditions': [for (final c in conditions) c.toJson()], + 'parameters': _encodeMap(parameters, (p) => p.toJson()), + 'parameterGroups': _encodeMap(parameterGroups, (g) => g.toJson()), + 'etag': etag, + 'version': ?version?.toJson(), + }; + } +} + +/// A server-style template used by [ServerTemplate.evaluate]. Distinct from +/// [RemoteConfigTemplate] in that conditions are structured trees instead of +/// expression strings. +class ServerTemplateData { + ServerTemplateData({ + List? conditions, + Map? parameters, + required this.etag, + this.version, + }) : conditions = List.unmodifiable( + conditions ?? const [], + ), + parameters = Map.unmodifiable( + parameters ?? const {}, + ); + + factory ServerTemplateData.fromJson(Map json) { + final etag = json['etag'] as String?; + if (etag == null || etag.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Invalid Remote Config template: missing or empty etag.', + ); + } + final versionJson = json['version']; + return ServerTemplateData( + conditions: switch (json['conditions']) { + null => const [], + final List list => [ + for (final item in list) + NamedCondition.fromJson(item as Map), + ], + _ => throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'Remote Config conditions must be an array.', + ), + }, + parameters: _decodeMap( + json['parameters'], + RemoteConfigParameter.fromJson, + ), + etag: etag, + version: versionJson is Map + ? Version.fromJson(versionJson) + : null, + ); + } + + /// Conditions in priority (descending) order. + final List conditions; + + /// Parameter map keyed by parameter name. + final Map parameters; + + /// ETag of the current template (read-only). + final String etag; + + /// Optional version metadata. + final Version? version; +} + +// --------------------------------------------------------------------------- +// Versions and listing +// --------------------------------------------------------------------------- + +/// Origin of a Remote Config template update (output only). +const updateOriginUnspecified = 'REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED'; + +/// Type of a Remote Config template update (output only). +const updateTypeUnspecified = 'REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED'; + +/// Metadata about a published Remote Config template version. +class Version { + const Version({ + this.versionNumber, + this.updateTime, + this.updateOrigin, + this.updateType, + this.updateUser, + this.description, + this.rollbackSource, + this.isLegacy, + }); + + factory Version.fromJson(Map json) { + DateTime? parsedTime; + final timeStr = json['updateTime'] as String?; + if (timeStr != null && timeStr.isNotEmpty) { + parsedTime = DateTime.tryParse(timeStr)?.toUtc(); + } + final updateUserJson = json['updateUser']; + return Version( + versionNumber: json['versionNumber'] as String?, + updateTime: parsedTime, + updateOrigin: json['updateOrigin'] as String?, + updateType: json['updateType'] as String?, + updateUser: updateUserJson is Map + ? RemoteConfigUser.fromJson(updateUserJson) + : null, + description: json['description'] as String?, + rollbackSource: json['rollbackSource'] as String?, + isLegacy: json['isLegacy'] as bool?, + ); + } + + /// Version number (int64 string). Output only. + final String? versionNumber; + + /// When this version was written to the backend. Output only. + final DateTime? updateTime; + + /// Source that triggered the update, as stamped by the Remote Config server + /// (e.g. `CONSOLE`, `REST_API`, an `ADMIN_SDK_*` variant, or + /// [updateOriginUnspecified]). The exact set of values is defined by the + /// Remote Config service; clients should treat this as an opaque string. + /// Output only. + final String? updateOrigin; + + /// Update type — e.g. `INCREMENTAL_UPDATE`, `FORCED_UPDATE`, `ROLLBACK`, + /// or [updateTypeUnspecified]. Output only. + final String? updateType; + + /// Account that performed the update. Output only. + final RemoteConfigUser? updateUser; + + /// User-provided description for this version. The only field on a Version + /// that is settable on input — all other fields are output only and any + /// values supplied at publish time are silently ignored by the server. + final String? description; + + /// Version number this was rolled back from (int64 string). Output only; + /// only present if this version is the result of a rollback. + final String? rollbackSource; + + /// Whether this version was published before version history was supported. + /// Output only. + final bool? isLegacy; + + Map toJson() { + return { + 'versionNumber': ?versionNumber, + 'updateTime': ?updateTime?.toUtc().toIso8601String(), + 'updateOrigin': ?updateOrigin, + 'updateType': ?updateType, + 'updateUser': ?updateUser?.toJson(), + 'description': ?description, + 'rollbackSource': ?rollbackSource, + 'isLegacy': ?isLegacy, + }; + } +} + +/// Options for [RemoteConfig.listVersions]. +class ListVersionsOptions { + ListVersionsOptions({ + this.pageSize, + this.pageToken, + this.endVersionNumber, + this.startTime, + this.endTime, + }) { + if (pageSize != null && (pageSize! < 1 || pageSize! > 300)) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'pageSize must be between 1 and 300 (inclusive), got $pageSize.', + ); + } + } + + /// Max items per page (1–300). + final int? pageSize; + + /// Continuation token from a previous list versions response. + final String? pageToken; + + /// Newest version number to include (int64 string). + final String? endVersionNumber; + + /// Earliest update time to include. + final DateTime? startTime; + + /// Latest update time to include (entries on or after this are omitted). + final DateTime? endTime; +} + +/// A page of [Version] metadata. +class ListVersionsResult { + ListVersionsResult({List? versions, this.nextPageToken}) + : versions = List.unmodifiable(versions ?? const []); + + factory ListVersionsResult.fromJson(Map json) { + return ListVersionsResult( + versions: switch (json['versions']) { + null => const [], + final List list => [ + for (final item in list) + Version.fromJson(item as Map), + ], + _ => const [], + }, + nextPageToken: json['nextPageToken'] as String?, + ); + } + + /// Versions in reverse chronological order. + final List versions; + + /// Token for the next page, or null if no more pages. + final String? nextPageToken; +} + +// --------------------------------------------------------------------------- +// Server-side evaluation +// --------------------------------------------------------------------------- + +/// Inputs to [ServerTemplate.evaluate]. +class EvaluationContext { + /// Builds an evaluation context. [customSignals] values must be `String` or + /// `num`; other types are rejected by the operator implementations. + const EvaluationContext({ + this.randomizationId, + this.customSignals = const {}, + }); + + /// Identifier used by [PercentCondition] to bucket users deterministically. + final String? randomizationId; + + /// Developer-defined signals keyed by signal name. Values are `String` or + /// `num`. + final Map customSignals; +} + +/// Configuration produced by [ServerTemplate.evaluate]. +class ServerConfig { + /// Internal: build a [ServerConfig] from an evaluated value map. + @internal + ServerConfig.internal(Map values) + : _configValues = Map.unmodifiable(values); + + final Map _configValues; + + /// Returns the value for [key] as a boolean. + bool getBoolean(String key) => getValue(key).asBoolean(); + + /// Returns the value for [key] as an int. + /// + /// Returns `0` if the value cannot be parsed as an integer. + int getInt(String key) => getValue(key).asInt(); + + /// Returns the value for [key] as a double. + /// + /// Returns `0.0` if the value cannot be parsed as a double. + double getDouble(String key) => getValue(key).asDouble(); + + /// Returns the value for [key] as a string. + String getString(String key) => getValue(key).asString(); + + /// Returns the [Value] for [key], or a static default if unknown. + Value getValue(String key) { + return _configValues[key] ?? const Value._(ValueSource.valueStatic, ''); + } + + /// Returns a copy of every config value. + Map getAll() => Map.from(_configValues); +} + +/// Wraps a Remote Config parameter value with type-safe getters. +class Value { + const Value._(this._source, [this._value = '']); + + /// Internal: builds a [Value] from a raw string and source. + @internal + factory Value.internal(ValueSource source, [String value = '']) { + return Value._(source, value); + } + + static const _truthy = {'1', 'true', 't', 'yes', 'y', 'on'}; + + final ValueSource _source; + final String _value; + + /// Returns the raw string value. + String asString() => _value; + + /// Returns the value as a boolean. + /// + /// Truthy strings (case-insensitive): `1`, `true`, `t`, `yes`, `y`, `on`. + /// Static-source values always return `false`. + bool asBoolean() { + if (_source == ValueSource.valueStatic) return false; + return _truthy.contains(_value.toLowerCase()); + } + + /// Returns the value as an int. + /// + /// Static-source values always return `0`. Strings that don't parse as an + /// integer (including float strings like `"3.14"`) return `0` — use + /// [asDouble] for those. + int asInt() { + if (_source == ValueSource.valueStatic) return 0; + return int.tryParse(_value) ?? 0; + } + + /// Returns the value as a double. + /// + /// Static-source values always return `0.0`. Unparsable strings return `0.0`. + double asDouble() { + if (_source == ValueSource.valueStatic) return 0; + return double.tryParse(_value) ?? 0; + } + + /// Returns the source of this value. + ValueSource getSource() => _source; +} + +/// Options for [RemoteConfig.getServerTemplate]. +class GetServerTemplateOptions { + GetServerTemplateOptions({Map? defaultConfig}) + : defaultConfig = defaultConfig == null + ? null + : Map.unmodifiable(defaultConfig); + + /// Default config values used by [ServerConfig] for keys not defined in the + /// evaluated template. Values must be `String`, `num`, or `bool`. + final Map? defaultConfig; +} + +/// Options for [RemoteConfig.initServerTemplate]. +class InitServerTemplateOptions extends GetServerTemplateOptions { + InitServerTemplateOptions({super.defaultConfig, this.template}); + + /// Optional pre-loaded template — either a [ServerTemplateData] instance or + /// a JSON string in [ServerTemplateData] shape. + final Object? template; +} diff --git a/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_exception.dart b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_exception.dart new file mode 100644 index 00000000..850871b2 --- /dev/null +++ b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_exception.dart @@ -0,0 +1,147 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of 'remote_config.dart'; + +/// Mapping from Google API status codes to [RemoteConfigErrorCode] values. +@internal +const remoteConfigErrorCodeMapping = { + 'ABORTED': RemoteConfigErrorCode.aborted, + 'ALREADY_EXISTS': RemoteConfigErrorCode.alreadyExists, + 'INVALID_ARGUMENT': RemoteConfigErrorCode.invalidArgument, + 'INTERNAL': RemoteConfigErrorCode.internalError, + 'FAILED_PRECONDITION': RemoteConfigErrorCode.failedPrecondition, + 'NOT_FOUND': RemoteConfigErrorCode.notFound, + 'OUT_OF_RANGE': RemoteConfigErrorCode.outOfRange, + 'PERMISSION_DENIED': RemoteConfigErrorCode.permissionDenied, + 'RESOURCE_EXHAUSTED': RemoteConfigErrorCode.resourceExhausted, + 'UNAUTHENTICATED': RemoteConfigErrorCode.unauthenticated, + 'UNKNOWN': RemoteConfigErrorCode.unknownError, +}; + +/// Remote Config error code values. +enum RemoteConfigErrorCode { + aborted('aborted'), + alreadyExists('already-exists'), + invalidArgument('invalid-argument'), + internalError('internal-error'), + failedPrecondition('failed-precondition'), + notFound('not-found'), + outOfRange('out-of-range'), + permissionDenied('permission-denied'), + resourceExhausted('resource-exhausted'), + unauthenticated('unauthenticated'), + unknownError('unknown-error'); + + const RemoteConfigErrorCode(this.code); + + /// The string identifier for this error code. + final String code; +} + +/// Firebase Remote Config exception. +class FirebaseRemoteConfigException extends FirebaseAdminException + implements Exception { + FirebaseRemoteConfigException(this.errorCode, [String? message]) + : super(FirebaseServiceType.remoteConfig.name, errorCode.code, message); + + /// Builds an exception from a server error response. + /// + /// Inspects the JSON body for a structured error code and message; falls back + /// to the HTTP status when the body is not parseable. + @internal + factory FirebaseRemoteConfigException.fromServerError({ + required int? statusCode, + required String body, + required bool isJson, + }) { + if (isJson) { + try { + final json = jsonDecode(body); + final errorCode = _serverErrorCode(json); + final message = _serverErrorMessage(json) ?? 'Server error.'; + final mapped = errorCode != null + ? remoteConfigErrorCodeMapping[errorCode] + : null; + if (mapped != null) { + return FirebaseRemoteConfigException(mapped, message); + } + } on FormatException { + // fall through + } + } + + final byStatus = switch (statusCode) { + 400 => RemoteConfigErrorCode.invalidArgument, + 401 || 403 => RemoteConfigErrorCode.unauthenticated, + 404 => RemoteConfigErrorCode.notFound, + 409 => RemoteConfigErrorCode.alreadyExists, + 412 => RemoteConfigErrorCode.failedPrecondition, + 429 => RemoteConfigErrorCode.resourceExhausted, + 500 => RemoteConfigErrorCode.internalError, + _ => RemoteConfigErrorCode.unknownError, + }; + return FirebaseRemoteConfigException( + byStatus, + 'Unexpected response with status: $statusCode and body: $body', + ); + } + + /// The structured error code associated with this exception. + final RemoteConfigErrorCode errorCode; + + @override + String toString() => 'FirebaseRemoteConfigException: $code: $message'; +} + +String? _serverErrorCode(Object? response) { + if (response is! Map || !response.containsKey('error')) return null; + final error = response['error']; + if (error is String) return error; + if (error is Map) { + if (error['status'] is String) return error['status'] as String; + if (error['code'] is String) return error['code'] as String; + } + return null; +} + +String? _serverErrorMessage(Object? response) { + if (response is Map) { + final error = response['error']; + if (error is Map && error['message'] is String) { + return error['message'] as String; + } + } + return null; +} + +/// Wraps the body of an async function so any thrown exception is rethrown as +/// a [FirebaseRemoteConfigException] when applicable. +Future _rcGuard(FutureOr Function() fn) async { + try { + final value = fn(); + if (value is T) return value; + return await value; + } on FirebaseRemoteConfigException { + rethrow; + } catch (error, stackTrace) { + Error.throwWithStackTrace( + FirebaseRemoteConfigException( + RemoteConfigErrorCode.unknownError, + 'Unexpected error: $error', + ), + stackTrace, + ); + } +} diff --git a/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_http_client.dart b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_http_client.dart new file mode 100644 index 00000000..7bc1ca4d --- /dev/null +++ b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_http_client.dart @@ -0,0 +1,193 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of 'remote_config.dart'; + +const _rcApiHost = 'firebaseremoteconfig.googleapis.com'; + +/// Required so the server returns the `ETag` response header. +/// +/// See https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates +const _rcDefaultHeaders = {'Accept-Encoding': 'gzip'}; + +/// Result of a Remote Config HTTP call: parsed JSON body plus the response +/// `ETag` header (when present). +@internal +typedef RemoteConfigHttpResult = ({Map body, String? etag}); + +/// Low-level HTTP client for the Remote Config REST API. +/// +/// Wraps the authenticated client returned by [FirebaseApp] with REST-specific +/// path building, header injection, and error mapping. Higher-level +/// orchestration (validation, data-class conversion) lives in the request +/// handler. +class RemoteConfigHttpClient { + RemoteConfigHttpClient(this.app); + + /// The owning Firebase app. + final FirebaseApp app; + + String _basePath(String projectId) => '/v1/projects/$projectId'; + + Future _run( + Future Function(googleapis_auth.AuthClient client, String projectId) fn, + ) async { + final client = await app.client; + final projectId = await app.getProjectId(); + return _rcGuard(() => fn(client, projectId)); + } + + /// `GET /v1/projects/{projectId}/remoteConfig[?versionNumber=N]` + Future getTemplate({String? versionNumber}) { + return _run((client, projectId) async { + final query = {'versionNumber': ?versionNumber}; + final uri = Uri.https( + _rcApiHost, + '${_basePath(projectId)}/remoteConfig', + query.isEmpty ? null : query, + ); + return _send(client, 'GET', uri); + }); + } + + /// `PUT /v1/projects/{projectId}/remoteConfig[?validate_only=true]` + /// + /// [etag] is sent as the `If-Match` header (or `*` to force). + Future publishTemplate({ + required Map body, + required String etag, + bool validateOnly = false, + }) { + return _run((client, projectId) async { + final uri = Uri.https( + _rcApiHost, + '${_basePath(projectId)}/remoteConfig', + validateOnly ? const {'validate_only': 'true'} : null, + ); + return _send( + client, + 'PUT', + uri, + body: body, + extraHeaders: {'If-Match': etag}, + ); + }); + } + + /// `POST /v1/projects/{projectId}/remoteConfig:rollback` + Future rollback(String versionNumber) { + return _run((client, projectId) async { + final uri = Uri.https( + _rcApiHost, + '${_basePath(projectId)}/remoteConfig:rollback', + ); + return _send( + client, + 'POST', + uri, + body: {'versionNumber': versionNumber}, + ); + }); + } + + /// `GET /v1/projects/{projectId}/remoteConfig:listVersions?...` + Future> listVersions({ + int? pageSize, + String? pageToken, + String? endVersionNumber, + DateTime? startTime, + DateTime? endTime, + }) { + return _run((client, projectId) async { + final query = { + 'pageSize': ?pageSize?.toString(), + 'pageToken': ?pageToken, + 'endVersionNumber': ?endVersionNumber, + 'startTime': ?startTime?.toUtc().toIso8601String(), + 'endTime': ?endTime?.toUtc().toIso8601String(), + }; + final uri = Uri.https( + _rcApiHost, + '${_basePath(projectId)}/remoteConfig:listVersions', + query.isEmpty ? null : query, + ); + final result = await _send(client, 'GET', uri); + return result.body; + }); + } + + /// `GET /v1/projects/{projectId}/namespaces/firebase-server/serverRemoteConfig` + Future getServerTemplate() { + return _run((client, projectId) async { + final uri = Uri.https( + _rcApiHost, + '${_basePath(projectId)}/namespaces/firebase-server/serverRemoteConfig', + ); + return _send(client, 'GET', uri); + }); + } + + Future _send( + googleapis_auth.AuthClient client, + String method, + Uri uri, { + Map? body, + Map? extraHeaders, + }) async { + final headers = { + ..._rcDefaultHeaders, + if (body != null) 'Content-Type': 'application/json', + ...?extraHeaders, + }; + final encodedBody = body == null ? null : jsonEncode(body); + + final Response response; + switch (method) { + case 'GET': + response = await client.get(uri, headers: headers); + case 'POST': + response = await client.post(uri, headers: headers, body: encodedBody); + case 'PUT': + response = await client.put(uri, headers: headers, body: encodedBody); + default: + throw StateError('Unsupported HTTP method: $method'); + } + + final etag = response.headers['etag']; + final status = response.statusCode; + + if (status < 200 || status >= 300) { + final isJson = (response.headers['content-type'] ?? '').contains( + 'application/json', + ); + throw FirebaseRemoteConfigException.fromServerError( + statusCode: status, + body: response.body, + isJson: isJson, + ); + } + + if (response.body.isEmpty) { + return (body: const {}, etag: etag); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.unknownError, + 'Expected JSON object in response body, got ${decoded.runtimeType}.', + ); + } + return (body: decoded, etag: etag); + } +} diff --git a/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_request_handler.dart b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_request_handler.dart new file mode 100644 index 00000000..0aaf7f26 --- /dev/null +++ b/packages/firebase_admin_sdk/lib/src/remote_config/remote_config_request_handler.dart @@ -0,0 +1,163 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of 'remote_config.dart'; + +/// Orchestrates Remote Config operations: validation, JSON ↔ data-class +/// conversion, and ETag handling. Delegates raw HTTP work to +/// [RemoteConfigHttpClient]. +@internal +class RemoteConfigRequestHandler { + RemoteConfigRequestHandler( + FirebaseApp app, { + RemoteConfigHttpClient? httpClient, + }) : _httpClient = httpClient ?? RemoteConfigHttpClient(app); + + final RemoteConfigHttpClient _httpClient; + + Future getTemplate([String? versionNumber]) async { + if (versionNumber != null) { + _validateVersionNumber(versionNumber); + } + final result = await _httpClient.getTemplate(versionNumber: versionNumber); + return _parseTemplate(result); + } + + Future validateTemplate( + RemoteConfigTemplate template, + ) async { + _validateTemplate(template); + final result = await _httpClient.publishTemplate( + body: _buildRequestBody(template), + etag: template.etag, + validateOnly: true, + ); + // The validate-only response returns an etag with a `-0` suffix to indicate + // success. Restore the original etag so callers can use the template for + // follow-on operations. + if (result.etag == null || result.etag!.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'ETag header missing from validateTemplate response.', + ); + } + final parsed = RemoteConfigTemplate.fromJson({ + ...result.body, + 'etag': template.etag, + }); + return parsed; + } + + Future publishTemplate( + RemoteConfigTemplate template, { + bool force = false, + }) async { + _validateTemplate(template); + final result = await _httpClient.publishTemplate( + body: _buildRequestBody(template), + etag: force ? '*' : template.etag, + ); + return _parseTemplate(result); + } + + Future rollback(String versionNumber) async { + _validateVersionNumber(versionNumber); + final result = await _httpClient.rollback(versionNumber); + return _parseTemplate(result); + } + + Future listVersions([ + ListVersionsOptions? options, + ]) async { + if (options?.endVersionNumber != null) { + _validateVersionNumber(options!.endVersionNumber!, 'endVersionNumber'); + } + final body = await _httpClient.listVersions( + pageSize: options?.pageSize, + pageToken: options?.pageToken, + endVersionNumber: options?.endVersionNumber, + startTime: options?.startTime, + endTime: options?.endTime, + ); + return ListVersionsResult.fromJson(body); + } + + Future getServerTemplate() async { + final result = await _httpClient.getServerTemplate(); + if (result.etag == null || result.etag!.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'ETag header missing from getServerTemplate response.', + ); + } + return ServerTemplateData.fromJson({ + ...result.body, + 'etag': result.etag, + }); + } + + RemoteConfigTemplate _parseTemplate(RemoteConfigHttpResult result) { + if (result.etag == null || result.etag!.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'ETag header missing from response.', + ); + } + return RemoteConfigTemplate.fromJson({ + ...result.body, + 'etag': result.etag, + }); + } + + Map _buildRequestBody(RemoteConfigTemplate template) { + // The PUT body carries everything except the etag (sent as `If-Match`), + // and version metadata is stripped of output-only fields — only the + // user-provided description is allowed on input. + final body = template.toJson()..remove('etag'); + final description = template.version?.description; + if (description != null) { + body['version'] = {'description': description}; + } else { + body.remove('version'); + } + return body; + } + + void _validateTemplate(RemoteConfigTemplate template) { + if (template.etag.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'ETag must be a non-empty string.', + ); + } + } + + void _validateVersionNumber( + String versionNumber, [ + String propertyName = 'versionNumber', + ]) { + if (versionNumber.isEmpty) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + '$propertyName must be a non-empty string in int64 format.', + ); + } + if (BigInt.tryParse(versionNumber) == null) { + throw FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + '$propertyName must be an integer in int64 format.', + ); + } + } +} diff --git a/packages/firebase_admin_sdk/pubspec.yaml b/packages/firebase_admin_sdk/pubspec.yaml index a04dd98a..f5f299be 100644 --- a/packages/firebase_admin_sdk/pubspec.yaml +++ b/packages/firebase_admin_sdk/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: asn1lib: ^1.6.0 collection: ^1.19.1 + crypto: ^3.0.7 dart_jsonwebtoken: ^3.2.0 ffi: ^2.2.0 google_cloud: ^0.4.0 diff --git a/packages/firebase_admin_sdk/test/integration/remote_config/remote_config_test.dart b/packages/firebase_admin_sdk/test/integration/remote_config/remote_config_test.dart new file mode 100644 index 00000000..c38d167c --- /dev/null +++ b/packages/firebase_admin_sdk/test/integration/remote_config/remote_config_test.dart @@ -0,0 +1,319 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Firebase Remote Config integration tests. +// +// SAFETY: Remote Config has no emulator, so these tests hit the real API. +// They run a publish/rollback cycle against the project identified by the +// `RC_TEST_PROJECT_ID` env var. The rollback at the end of the suite +// restores the project's prior template version, so the net change to the +// project is zero on a successful run. +// +// Local: `gcloud beta auth application-default login`, +// `export RC_TEST_PROJECT_ID=`, and +// `RUN_PROD_TESTS=true dart test test/integration/remote_config/`. +// CI: runs in the `test-wif` GitHub Actions job, which authenticates +// via Workload Identity Federation; the bound service account must +// have `roles/firebaseremoteconfig.admin` on the project, and the +// project ID must be supplied via the `RC_TEST_PROJECT_ID` env var +// (e.g. via a workflow-level `env:` block backed by a secret). + +import 'dart:io'; + +import 'package:firebase_admin_sdk/firebase_admin_sdk.dart'; +import 'package:firebase_admin_sdk/remote_config.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/helpers.dart'; + +final _rcProjectId = Platform.environment['RC_TEST_PROJECT_ID']; + +const _validConditions = [ + RemoteConfigCondition( + name: 'ios', + expression: "device.os == 'ios'", + tagColor: TagColor.indigo, + ), + RemoteConfigCondition( + name: 'android', + expression: "device.os == 'android'", + tagColor: TagColor.green, + ), +]; + +// `RemoteConfigParameter` isn't const (it wraps `conditionalValues` in +// `Map.unmodifiable` internally), so this is `final` rather than `const`. +final _validParameter = RemoteConfigParameter( + defaultValue: const ExplicitParameterValue(value: 'hello'), + description: 'dart_admin_sdk e2e test parameter', + valueType: ParameterValueType.string, +); + +void main() { + // Three conditions must hold for the suite to run: + // - RC_TEST_PROJECT_ID is set (target project for the round-trip). + // - Credentials are available (CI WIF sets GOOGLE_APPLICATION_CREDENTIALS; + // local opt-in via RUN_PROD_TESTS=true). + final projectId = _rcProjectId; + final shouldRun = projectId != null && (hasWifEnv || hasProdEnv); + final skipReason = shouldRun + ? null + : 'Requires RC_TEST_PROJECT_ID plus GOOGLE_APPLICATION_CREDENTIALS or RUN_PROD_TESTS=true'; + + group('RemoteConfig (production)', () { + late FirebaseApp app; + late RemoteConfig rc; + late RemoteConfigTemplate baseline; + + setUpAll(() async { + app = FirebaseApp.initializeApp( + name: 'rc-e2e-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId), + ); + rc = app.remoteConfig(); + baseline = await rc.getTemplate(); + }); + + tearDownAll(() async { + await app.close(); + }); + + test('getTemplate returns a template with a non-empty etag', () { + expect(baseline.etag, isNotEmpty); + }); + + test('validateTemplate succeeds with a valid template', () async { + final candidate = RemoteConfigTemplate( + etag: baseline.etag, + conditions: _validConditions, + parameters: { + ...baseline.parameters, + 'dart_admin_sdk_e2e_marker': _validParameter, + }, + parameterGroups: baseline.parameterGroups, + version: const Version(description: 'dart_admin_sdk e2e validate'), + ); + + final validated = await rc.validateTemplate(candidate); + // Validate-only restores the original etag in our request handler. + expect(validated.etag, baseline.etag); + }); + + test( + 'validateTemplate propagates invalid-argument when conditions reference unknown names', + () async { + final invalid = RemoteConfigTemplate( + etag: baseline.etag, + // No conditions defined, but the parameter below references + // a non-existent condition name. + conditions: const [], + parameters: { + ...baseline.parameters, + 'dart_admin_sdk_e2e_marker': RemoteConfigParameter( + defaultValue: const ExplicitParameterValue(value: 'x'), + conditionalValues: const { + 'never_declared_condition': ExplicitParameterValue(value: 'y'), + }, + ), + }, + parameterGroups: baseline.parameterGroups, + ); + + await expectLater( + rc.validateTemplate(invalid), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }, + ); + + test( + 'publishTemplate -> getTemplate -> rollback round-trip', + () async { + // 1. Publish a marker parameter on top of the current baseline. + final stamp = DateTime.now().toUtc().toIso8601String(); + final candidate = RemoteConfigTemplate( + etag: baseline.etag, + conditions: _validConditions, + parameters: { + ...baseline.parameters, + 'dart_admin_sdk_e2e_marker': RemoteConfigParameter( + defaultValue: ExplicitParameterValue(value: stamp), + description: 'e2e $stamp', + valueType: ParameterValueType.string, + ), + }, + parameterGroups: baseline.parameterGroups, + version: Version(description: 'dart_admin_sdk e2e $stamp'), + ); + + final published = await rc.publishTemplate(candidate); + expect(published.etag, isNot(equals(baseline.etag))); + expect( + published.parameters.containsKey('dart_admin_sdk_e2e_marker'), + isTrue, + ); + + // 2. Fetch and verify the marker is visible. + final fetched = await rc.getTemplate(); + final marker = fetched.parameters['dart_admin_sdk_e2e_marker']; + expect(marker, isNotNull); + expect((marker!.defaultValue! as ExplicitParameterValue).value, stamp); + + // 3. Roll back to the prior version; project state should match + // the baseline we captured in setUpAll. The bound service + // account needs `roles/firebaseanalytics.viewer` on the + // linked GA property in addition to + // `roles/firebaseremoteconfig.admin`, since RC's rollback + // endpoint validates against Google Analytics data. + final priorVersion = baseline.version?.versionNumber; + if (priorVersion != null) { + final rolledBack = await rc.rollback(priorVersion); + expect( + rolledBack.parameters.containsKey('dart_admin_sdk_e2e_marker'), + isFalse, + reason: 'rollback should remove the e2e marker parameter', + ); + } + }, + // Round-trip can take a bit; allow generous time. + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'listVersions returns at least the version we just published', + () async { + final result = await rc.listVersions(ListVersionsOptions(pageSize: 5)); + expect(result.versions, isNotEmpty); + // Newest first per the REST API. + expect(result.versions.first.versionNumber, isNotNull); + }, + ); + + test( + 'getTemplate(versionNumber) returns a specific historical version', + () async { + // Pick the latest known version from listVersions and re-fetch it + // explicitly. The returned template must echo that version number + // back in `version.versionNumber`. + final list = await rc.listVersions(ListVersionsOptions(pageSize: 1)); + expect(list.versions, isNotEmpty); + final versionNumber = list.versions.first.versionNumber; + expect(versionNumber, isNotNull); + + final atVersion = await rc.getTemplate(versionNumber); + expect(atVersion.etag, isNotEmpty); + expect(atVersion.version, isNotNull); + expect(atVersion.version!.versionNumber, versionNumber); + }, + ); + + test( + 'publishTemplate(force: true) bypasses ETag validation', + () async { + // 1. Build a candidate from the current active template, but stamp + // a deliberately stale ETag to force the version-mismatch path. + final current = await rc.getTemplate(); + final stamp = DateTime.now().toUtc().toIso8601String(); + final cleanCurrent = Map.from( + current.parameters, + )..remove('dart_admin_sdk_e2e_marker'); + final staleCandidate = RemoteConfigTemplate( + // Stale: not the server's actual current ETag. + etag: 'etag-deliberately-stale', + conditions: current.conditions, + parameters: { + ...cleanCurrent, + 'dart_admin_sdk_e2e_marker': RemoteConfigParameter( + defaultValue: ExplicitParameterValue(value: 'force-$stamp'), + description: 'e2e force-publish $stamp', + valueType: ParameterValueType.string, + ), + }, + parameterGroups: current.parameterGroups, + version: const Version(description: 'dart_admin_sdk e2e force'), + ); + + // 2. Without force, the server rejects the stale ETag. + await expectLater( + rc.publishTemplate(staleCandidate), + throwsA(isA()), + ); + + // 3. With force=true, the SDK sends `If-Match: *` and the publish + // succeeds despite the stale ETag in the candidate. + final published = await rc.publishTemplate(staleCandidate, force: true); + expect( + published.parameters.containsKey('dart_admin_sdk_e2e_marker'), + isTrue, + ); + + // 4. Cleanup: roll back to baseline so subsequent tests see the + // original state. + final priorVersion = baseline.version?.versionNumber; + if (priorVersion != null) { + await rc.rollback(priorVersion); + } + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + + test('getServerTemplate + evaluate produces a ServerConfig', () async { + // Server-side templates are a separate resource and can only be + // authored via the Firebase Console (the public REST API exposes + // GET on this namespace but no write path). If the target project + // has no server template configured, the API returns NOT_FOUND; + // we fall back to initServerTemplate with an empty cached template + // so the evaluator still smoke-tests cleanly. + ServerTemplate serverTemplate; + try { + serverTemplate = await rc.getServerTemplate( + defaultConfig: const { + 'dart_admin_sdk_e2e_in_app_only': 'fallback', + }, + ); + } on FirebaseRemoteConfigException catch (e) { + if (e.errorCode != RemoteConfigErrorCode.notFound) rethrow; + serverTemplate = rc.initServerTemplate( + defaultConfig: const { + 'dart_admin_sdk_e2e_in_app_only': 'fallback', + }, + template: '{"etag":"e2e-empty","conditions":[],"parameters":{}}', + ); + } + + final config = serverTemplate.evaluate( + const EvaluationContext(randomizationId: 'dart_admin_sdk_e2e_user'), + ); + + // A key that exists ONLY in the in-app defaultConfig (never published + // to the server template) must resolve from `valueDefault`. + expect( + config.getValue('dart_admin_sdk_e2e_in_app_only').getSource(), + ValueSource.valueDefault, + ); + expect(config.getString('dart_admin_sdk_e2e_in_app_only'), 'fallback'); + // Unknown keys fall through to static defaults. + expect( + config.getValue('definitely_not_a_real_key').getSource(), + ValueSource.valueStatic, + ); + }); + }, skip: skipReason); +} diff --git a/packages/firebase_admin_sdk/test/unit/remote_config/condition_evaluator_test.dart b/packages/firebase_admin_sdk/test/unit/remote_config/condition_evaluator_test.dart new file mode 100644 index 00000000..57ba8fa3 --- /dev/null +++ b/packages/firebase_admin_sdk/test/unit/remote_config/condition_evaluator_test.dart @@ -0,0 +1,610 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:firebase_admin_sdk/src/remote_config/remote_config.dart'; +import 'package:test/test.dart'; + +NamedCondition _named(String name, OneOfCondition condition) => + NamedCondition(name: name, condition: condition); + +void main() { + group('ConditionEvaluator', () { + late ConditionEvaluator evaluator; + + setUp(() { + evaluator = ConditionEvaluator(); + }); + + group('logical operators', () { + test('TrueCondition always evaluates to true', () { + final results = evaluator.evaluateConditions([ + _named('always-true', const TrueCondition()), + ], const EvaluationContext()); + expect(results['always-true'], isTrue); + }); + + test('FalseCondition always evaluates to false', () { + final results = evaluator.evaluateConditions([ + _named('always-false', const FalseCondition()), + ], const EvaluationContext()); + expect(results['always-false'], isFalse); + }); + + test('OrCondition: short-circuits to true on first true child', () { + final cond = const OrCondition( + conditions: [TrueCondition(), FalseCondition()], + ); + expect( + evaluator.evaluateConditions([ + _named('or', cond), + ], const EvaluationContext())['or'], + isTrue, + ); + }); + + test('OrCondition: false when all children false', () { + final cond = const OrCondition( + conditions: [FalseCondition(), FalseCondition()], + ); + expect( + evaluator.evaluateConditions([ + _named('or', cond), + ], const EvaluationContext())['or'], + isFalse, + ); + }); + + test('OrCondition: empty/null conditions evaluate to false', () { + final emptyOr = const OrCondition(conditions: []); + final nullOr = const OrCondition(); + final results = evaluator.evaluateConditions([ + _named('empty', emptyOr), + _named('null', nullOr), + ], const EvaluationContext()); + expect(results['empty'], isFalse); + expect(results['null'], isFalse); + }); + + test('AndCondition: true when all children true', () { + final cond = const AndCondition( + conditions: [TrueCondition(), TrueCondition()], + ); + expect( + evaluator.evaluateConditions([ + _named('and', cond), + ], const EvaluationContext())['and'], + isTrue, + ); + }); + + test('AndCondition: short-circuits to false on first false child', () { + final cond = const AndCondition( + conditions: [TrueCondition(), FalseCondition()], + ); + expect( + evaluator.evaluateConditions([ + _named('and', cond), + ], const EvaluationContext())['and'], + isFalse, + ); + }); + + test('AndCondition: empty/null conditions evaluate to true', () { + final emptyAnd = const AndCondition(conditions: []); + final nullAnd = const AndCondition(); + final results = evaluator.evaluateConditions([ + _named('empty', emptyAnd), + _named('null', nullAnd), + ], const EvaluationContext()); + expect(results['empty'], isTrue); + expect(results['null'], isTrue); + }); + + test('preserves condition order in result map', () { + final results = evaluator.evaluateConditions([ + _named('z', const TrueCondition()), + _named('a', const FalseCondition()), + _named('m', const TrueCondition()), + ], const EvaluationContext()); + expect(results.keys.toList(), ['z', 'a', 'm']); + }); + + test('recursion deeper than 10 levels evaluates to false', () { + // Build a chain of 12 nested OR(TRUE) wrappers; the inner TRUE + // should be unreachable past the depth limit. + OneOfCondition deep = const TrueCondition(); + for (var i = 0; i < 12; i++) { + deep = OrCondition(conditions: [deep]); + } + expect( + evaluator.evaluateConditions([ + _named('deep', deep), + ], const EvaluationContext())['deep'], + isFalse, + ); + }); + }); + + group('PercentCondition', () { + test('returns false when randomizationId is missing', () { + final cond = PercentCondition( + percentOperator: PercentConditionOperator.lessOrEqual, + microPercent: 50000000, + ); + expect( + evaluator.evaluateConditions([ + _named('p', cond), + ], const EvaluationContext())['p'], + isFalse, + ); + }); + + test('returns false when operator is unknown / null', () { + final cond = PercentCondition(microPercent: 50000000); + expect( + evaluator.evaluateConditions([ + _named('p', cond), + ], const EvaluationContext(randomizationId: 'user-1'))['p'], + isFalse, + ); + }); + + test('LESS_OR_EQUAL with microPercent=100M targets all instances', () { + final cond = PercentCondition( + percentOperator: PercentConditionOperator.lessOrEqual, + microPercent: 100000000, + ); + for (final id in const ['a', 'b', 'c', 'user-1', 'user-2']) { + expect( + evaluator.evaluateConditions([ + _named('p', cond), + ], EvaluationContext(randomizationId: id))['p'], + isTrue, + reason: 'id=$id should be in 100% bucket', + ); + } + }); + + test('LESS_OR_EQUAL with microPercent=0 targets no instances', () { + final cond = PercentCondition( + percentOperator: PercentConditionOperator.lessOrEqual, + microPercent: 0, + ); + for (final id in const ['a', 'b', 'c', 'user-1', 'user-2']) { + expect( + evaluator.evaluateConditions([ + _named('p', cond), + ], EvaluationContext(randomizationId: id))['p'], + isFalse, + reason: 'id=$id should not be in 0% bucket', + ); + } + }); + + test('GREATER_THAN with microPercent=0 targets all instances', () { + final cond = PercentCondition( + percentOperator: PercentConditionOperator.greaterThan, + microPercent: 0, + ); + // microPercentile is in [0, 99999999], strictly greater than 0 for + // most non-zero hashes — but a hash that mods to exactly 0 will fail. + // We assert across several IDs to catch the typical case. + var hits = 0; + for (final id in const ['a', 'b', 'c', 'user-1', 'user-2']) { + if (evaluator.evaluateConditions([ + _named('p', cond), + ], EvaluationContext(randomizationId: id))['p'] == + true) { + hits++; + } + } + expect(hits, greaterThanOrEqualTo(4)); + }); + + test('BETWEEN with full range [0, 100M] targets all instances', () { + final cond = PercentCondition( + percentOperator: PercentConditionOperator.between, + microPercentRange: const MicroPercentRange( + microPercentLowerBound: -1, // exclusive lower → include 0 + microPercentUpperBound: 100000000, + ), + ); + for (final id in const ['a', 'b', 'c']) { + expect( + evaluator.evaluateConditions([ + _named('p', cond), + ], EvaluationContext(randomizationId: id))['p'], + isTrue, + reason: 'id=$id should be in [0, 100M] bucket', + ); + } + }); + + test('SHA-256 hash produces a deterministic BigInt for known input', () { + // Pre-computed: SHA-256("test-seed.user-1") in hex, as BigInt. + // Confirmed against the same string hashed via openssl/sha256sum. + const input = 'test-seed.user-1'; + final hash = ConditionEvaluator.hashSeededRandomizationIdForTest(input); + // Sanity: the hash must fit in 256 bits. + expect(hash, greaterThan(BigInt.zero)); + expect(hash.bitLength, lessThanOrEqualTo(256)); + }); + + test('seed is prefixed onto randomizationId with a "."', () { + // Two different seeds with the same id should usually produce + // different hashes (and hence different bucket assignments). + final cond1 = PercentCondition( + percentOperator: PercentConditionOperator.lessOrEqual, + microPercent: 50000000, // 50% + seed: 'seed-A', + ); + final cond2 = PercentCondition( + percentOperator: PercentConditionOperator.lessOrEqual, + microPercent: 50000000, + seed: 'seed-B', + ); + // Sample many ids; we expect SOME divergence between the two + // seeded buckets. + var divergent = 0; + for (var i = 0; i < 100; i++) { + final ctx = EvaluationContext(randomizationId: 'user-$i'); + final r1 = evaluator.evaluateConditions([ + _named('p', cond1), + ], ctx)['p']!; + final r2 = evaluator.evaluateConditions([ + _named('p', cond2), + ], ctx)['p']!; + if (r1 != r2) divergent++; + } + expect(divergent, greaterThan(10)); + }); + }); + + group('CustomSignalCondition - string operators', () { + EvaluationContext ctxWith(String key, Object value) => + EvaluationContext(customSignals: {key: value}); + + test('STRING_CONTAINS matches substring', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.stringContains, + customSignalKey: 'country', + targetCustomSignalValues: const ['US', 'CA'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('country', 'USA'))['c'], + isTrue, + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('country', 'JP'))['c'], + isFalse, + ); + }); + + test('STRING_DOES_NOT_CONTAIN is the negation', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.stringDoesNotContain, + customSignalKey: 'country', + targetCustomSignalValues: const ['US', 'CA'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('country', 'USA'))['c'], + isFalse, + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('country', 'JP'))['c'], + isTrue, + ); + }); + + test( + 'STRING_EXACTLY_MATCHES trims both sides and matches any target', + () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.stringExactlyMatches, + customSignalKey: 'tier', + targetCustomSignalValues: const ['gold', 'platinum'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('tier', ' gold '))['c'], + isTrue, + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('tier', 'silver'))['c'], + isFalse, + ); + }, + ); + + test('STRING_CONTAINS_REGEX matches via RegExp', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.stringContainsRegex, + customSignalKey: 'email', + targetCustomSignalValues: const [r'^.+@example\.com$'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('email', 'alice@example.com'))['c'], + isTrue, + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('email', 'alice@other.com'))['c'], + isFalse, + ); + }); + }); + + group('CustomSignalCondition - numeric operators', () { + EvaluationContext ctxWith(Object value) => + EvaluationContext(customSignals: {'age': value}); + + test('NUMERIC_LESS_THAN', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.numericLessThan, + customSignalKey: 'age', + targetCustomSignalValues: const ['30'], + ); + expect( + evaluator.evaluateConditions([_named('c', cond)], ctxWith(25))['c'], + isTrue, + ); + expect( + evaluator.evaluateConditions([_named('c', cond)], ctxWith(30))['c'], + isFalse, + ); + // Numeric value supplied as string also works. + expect( + evaluator.evaluateConditions([_named('c', cond)], ctxWith('29'))['c'], + isTrue, + ); + }); + + test('NUMERIC_LESS_EQUAL / EQUAL / NOT_EQUAL', () { + for (final entry in >{ + CustomSignalOperator.numericLessEqual: { + 29: true, + 30: true, + 31: false, + }, + CustomSignalOperator.numericEqual: {29: false, 30: true, 31: false}, + CustomSignalOperator.numericNotEqual: {29: true, 30: false, 31: true}, + }.entries) { + final cond = CustomSignalCondition( + customSignalOperator: entry.key, + customSignalKey: 'age', + targetCustomSignalValues: const ['30'], + ); + for (final tc in entry.value.entries) { + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith(tc.key))['c'], + tc.value, + reason: '${entry.key.name} with actual=${tc.key} target=30', + ); + } + } + }); + + test('NUMERIC_GREATER_THAN / GREATER_EQUAL', () { + for (final entry in >{ + CustomSignalOperator.numericGreaterThan: { + 29: false, + 30: false, + 31: true, + }, + CustomSignalOperator.numericGreaterEqual: { + 29: false, + 30: true, + 31: true, + }, + }.entries) { + final cond = CustomSignalCondition( + customSignalOperator: entry.key, + customSignalKey: 'age', + targetCustomSignalValues: const ['30'], + ); + for (final tc in entry.value.entries) { + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith(tc.key))['c'], + tc.value, + reason: '${entry.key.name} with actual=${tc.key} target=30', + ); + } + } + }); + + test('non-numeric actual or target returns false', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.numericEqual, + customSignalKey: 'age', + targetCustomSignalValues: const ['30'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('not-a-number'))['c'], + isFalse, + ); + }); + }); + + group('CustomSignalCondition - semver operators', () { + EvaluationContext ctxWith(String version) => + EvaluationContext(customSignals: {'version': version}); + + test('SEMANTIC_VERSION_LESS_THAN with multi-segment versions', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.semanticVersionLessThan, + customSignalKey: 'version', + targetCustomSignalValues: const ['2.0.0'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('1.9.9'))['c'], + isTrue, + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('2.0.0'))['c'], + isFalse, + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('2.0.1'))['c'], + isFalse, + ); + }); + + test('SEMANTIC_VERSION_EQUAL handles missing trailing segments', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.semanticVersionEqual, + customSignalKey: 'version', + targetCustomSignalValues: const ['1.2'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('1.2.0'))['c'], + isTrue, + reason: 'missing trailing segments are treated as 0', + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('1.2.1'))['c'], + isFalse, + ); + }); + + test('non-numeric segment returns false', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.semanticVersionEqual, + customSignalKey: 'version', + targetCustomSignalValues: const ['1.2.0'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('1.2-alpha'))['c'], + isFalse, + reason: '"alpha" is not numeric', + ); + }); + + test('versions longer than 5 segments evaluate to false', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.semanticVersionEqual, + customSignalKey: 'version', + targetCustomSignalValues: const ['1.0.0.0.0.0'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], ctxWith('1.0.0.0.0.0'))['c'], + isFalse, + ); + }); + + test('semver greater/less variants', () { + final lt = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.semanticVersionLessEqual, + customSignalKey: 'version', + targetCustomSignalValues: const ['2.0.0'], + ); + final gte = CustomSignalCondition( + customSignalOperator: + CustomSignalOperator.semanticVersionGreaterEqual, + customSignalKey: 'version', + targetCustomSignalValues: const ['2.0.0'], + ); + expect( + evaluator.evaluateConditions([ + _named('lt', lt), + _named('gte', gte), + ], ctxWith('2.0.0')), + {'lt': true, 'gte': true}, + ); + expect( + evaluator.evaluateConditions([ + _named('lt', lt), + _named('gte', gte), + ], ctxWith('2.0.1')), + {'lt': false, 'gte': true}, + ); + }); + }); + + group('CustomSignalCondition - missing inputs', () { + test('missing customSignalKey returns false', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.numericEqual, + targetCustomSignalValues: const ['1'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], const EvaluationContext(customSignals: {'foo': 1}))['c'], + isFalse, + ); + }); + + test('missing targetCustomSignalValues returns false', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.numericEqual, + customSignalKey: 'foo', + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], const EvaluationContext(customSignals: {'foo': 1}))['c'], + isFalse, + ); + }); + + test('signal key absent in context returns false', () { + final cond = CustomSignalCondition( + customSignalOperator: CustomSignalOperator.numericEqual, + customSignalKey: 'missing', + targetCustomSignalValues: const ['1'], + ); + expect( + evaluator.evaluateConditions([ + _named('c', cond), + ], const EvaluationContext(customSignals: {'foo': 1}))['c'], + isFalse, + ); + }); + }); + }); +} diff --git a/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_api_test.dart b/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_api_test.dart new file mode 100644 index 00000000..5bcc3970 --- /dev/null +++ b/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_api_test.dart @@ -0,0 +1,425 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:firebase_admin_sdk/remote_config.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/helpers.dart'; + +void main() { + late RemoteConfig rc; + + setUp(() { + final app = createApp( + name: 'rc-api-test-${DateTime.now().microsecondsSinceEpoch}', + ); + rc = app.remoteConfig(); + }); + + group('RemoteConfig data classes', () { + test('createTemplateFromJson parses a representative template', () { + const json = '''{ + "etag": "etag-1", + "conditions": [ + {"name": "ios", "expression": "device.os == 'iOS'", "tagColor": "BLUE"} + ], + "parameters": { + "welcome_message": { + "defaultValue": {"value": "Hello"}, + "conditionalValues": { + "ios": {"value": "Hi from iOS"} + }, + "description": "Greeting", + "valueType": "STRING" + }, + "feature_flag": { + "defaultValue": {"useInAppDefault": true}, + "valueType": "BOOLEAN" + } + }, + "parameterGroups": { + "ui": { + "description": "UI parameters", + "parameters": { + "color": {"defaultValue": {"value": "blue"}} + } + } + }, + "version": { + "versionNumber": "42", + "updateTime": "2026-04-25T10:00:00Z", + "updateOrigin": "REST_API", + "updateType": "INCREMENTAL_UPDATE", + "description": "Initial publish" + } + }'''; + + final template = rc.createTemplateFromJson(json); + + expect(template.etag, 'etag-1'); + expect(template.conditions, hasLength(1)); + expect(template.conditions[0].name, 'ios'); + expect(template.conditions[0].expression, "device.os == 'iOS'"); + expect(template.conditions[0].tagColor, TagColor.blue); + + expect(template.parameters, hasLength(2)); + final welcome = template.parameters['welcome_message']!; + expect(welcome.defaultValue, isA()); + expect((welcome.defaultValue! as ExplicitParameterValue).value, 'Hello'); + expect(welcome.conditionalValues, hasLength(1)); + expect(welcome.description, 'Greeting'); + expect(welcome.valueType, ParameterValueType.string); + + final flag = template.parameters['feature_flag']!; + expect(flag.defaultValue, isA()); + expect((flag.defaultValue! as InAppDefaultValue).useInAppDefault, true); + expect(flag.valueType, ParameterValueType.boolean); + + expect(template.parameterGroups, hasLength(1)); + expect( + template.parameterGroups['ui']!.parameters['color']!.defaultValue, + isA(), + ); + + expect(template.version, isNotNull); + expect(template.version!.versionNumber, '42'); + expect(template.version!.updateOrigin, 'REST_API'); + expect(template.version!.description, 'Initial publish'); + }); + + test('createTemplateFromJson throws on missing etag', () { + const json = + '{"conditions": [], "parameters": {}, "parameterGroups": {}}'; + expect( + () => rc.createTemplateFromJson(json), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + + test('createTemplateFromJson throws on malformed JSON', () { + expect( + () => rc.createTemplateFromJson('not-json'), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + + test('createTemplateFromJson throws on empty input', () { + expect( + () => rc.createTemplateFromJson(''), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + + test('createTemplateFromJson throws when JSON decodes to non-object', () { + expect( + () => rc.createTemplateFromJson('[1, 2, 3]'), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + }); + + group('Constructor validation', () { + test('PercentCondition rejects out-of-range microPercent', () { + expect( + () => PercentCondition(microPercent: -1), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + expect( + () => PercentCondition(microPercent: 100000001), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + // Boundaries OK. + expect(PercentCondition(microPercent: 0).microPercent, 0); + expect(PercentCondition(microPercent: 100000000).microPercent, 100000000); + }); + + test('PercentCondition rejects seed longer than 32 characters', () { + expect( + () => PercentCondition(seed: 'x' * 33), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + expect(PercentCondition(seed: 'x' * 32).seed, 'x' * 32); + }); + + test('CustomSignalCondition rejects empty / oversized target arrays', () { + expect( + () => CustomSignalCondition(targetCustomSignalValues: const []), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + expect( + () => CustomSignalCondition( + targetCustomSignalValues: List.filled(101, 'x'), + ), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + + test('ListVersionsOptions rejects pageSize out of [1, 300]', () { + expect( + () => ListVersionsOptions(pageSize: 0), + throwsA(isA()), + ); + expect( + () => ListVersionsOptions(pageSize: 301), + throwsA(isA()), + ); + expect(ListVersionsOptions(pageSize: 1).pageSize, 1); + expect(ListVersionsOptions(pageSize: 300).pageSize, 300); + }); + }); + + group('Value source-dependent getters', () { + test('static source always returns defaults', () { + final v = Value.internal(ValueSource.valueStatic); + expect(v.asBoolean(), false); + expect(v.asInt(), 0); + expect(v.asDouble(), 0.0); + expect(v.asString(), ''); + }); + + test('truthy strings (case-insensitive) → true', () { + for (final s in const [ + '1', + 'true', + 't', + 'yes', + 'y', + 'on', + 'TRUE', + 'On', + 'YES', + ]) { + expect( + Value.internal(ValueSource.valueRemote, s).asBoolean(), + isTrue, + reason: '"$s" should be truthy', + ); + } + }); + + test('non-truthy strings → false', () { + for (final s in const ['0', 'false', 'no', '', 'foo', 'off']) { + expect( + Value.internal(ValueSource.valueRemote, s).asBoolean(), + isFalse, + reason: '"$s" should be falsy', + ); + } + }); + + test('asInt parses integer strings; non-integers return 0', () { + expect(Value.internal(ValueSource.valueRemote, '42').asInt(), 42); + expect(Value.internal(ValueSource.valueRemote, '-5').asInt(), -5); + // Float strings don't parse as int — caller should use asDouble. + expect(Value.internal(ValueSource.valueRemote, '3.14').asInt(), 0); + // Unparsable → 0. + expect(Value.internal(ValueSource.valueRemote, 'not-a-num').asInt(), 0); + }); + + test('asDouble parses int and float strings; unparsable returns 0.0', () { + expect(Value.internal(ValueSource.valueRemote, '42').asDouble(), 42.0); + expect(Value.internal(ValueSource.valueRemote, '3.14').asDouble(), 3.14); + expect(Value.internal(ValueSource.valueRemote, '-5').asDouble(), -5.0); + expect( + Value.internal(ValueSource.valueRemote, 'not-a-num').asDouble(), + 0.0, + ); + }); + + test('default-source values behave like remote', () { + final v = Value.internal(ValueSource.valueDefault, 'true'); + expect(v.asBoolean(), true); + expect(v.getSource(), ValueSource.valueDefault); + }); + }); + + group('ServerConfig', () { + test('getValue returns static default for unknown keys', () { + final config = ServerConfig.internal({}); + final v = config.getValue('missing'); + expect(v.getSource(), ValueSource.valueStatic); + expect(v.asBoolean(), false); + expect(v.asInt(), 0); + expect(v.asDouble(), 0.0); + expect(v.asString(), ''); + }); + + test('getAll returns a copy', () { + final config = ServerConfig.internal({ + 'k': Value.internal(ValueSource.valueRemote, 'v'), + }); + final all = config.getAll(); + expect(all.keys, ['k']); + // Mutating the returned map must not affect the underlying config. + all['x'] = Value.internal(ValueSource.valueRemote, 'leaked'); + expect(config.getAll().containsKey('x'), isFalse); + }); + }); + + group('ServerTemplate.evaluate', () { + test('respects condition order, defaults, and in-app fallback', () { + const json = '''{ + "etag": "tmpl-1", + "conditions": [ + {"name": "always-true", "condition": {"true": {}}}, + {"name": "always-false", "condition": {"false": {}}} + ], + "parameters": { + "by_condition": { + "defaultValue": {"value": "default"}, + "conditionalValues": { + "always-true": {"value": "matched"} + } + }, + "fallback": { + "defaultValue": {"value": "fallback-default"} + }, + "skip_via_in_app": { + "defaultValue": {"useInAppDefault": true} + } + } + }'''; + + final template = rc.initServerTemplate( + defaultConfig: const { + 'skip_via_in_app': 'in-app-value', + 'unrelated': 'kept', + }, + template: json, + ); + final config = template.evaluate(); + + expect(config.getString('by_condition'), 'matched'); + expect( + config.getValue('by_condition').getSource(), + ValueSource.valueRemote, + ); + + expect(config.getString('fallback'), 'fallback-default'); + expect(config.getValue('fallback').getSource(), ValueSource.valueRemote); + + // skip_via_in_app should fall through to the in-app default config. + expect(config.getString('skip_via_in_app'), 'in-app-value'); + expect( + config.getValue('skip_via_in_app').getSource(), + ValueSource.valueDefault, + ); + + // Keys present only in defaultConfig pass through. + expect(config.getString('unrelated'), 'kept'); + + // Unknown keys → static default. + expect( + config.getValue('does-not-exist').getSource(), + ValueSource.valueStatic, + ); + }); + + test('throws failed-precondition when no template loaded', () { + final template = rc.initServerTemplate(); + expect( + template.evaluate, + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.failedPrecondition, + ), + ), + ); + }); + + test('set() rejects malformed JSON', () { + final template = rc.initServerTemplate(); + expect( + () => template.set('not-json'), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + + test('set() rejects unsupported types', () { + final template = rc.initServerTemplate(); + expect( + () => template.set(42), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + }); +} diff --git a/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_exception_test.dart b/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_exception_test.dart new file mode 100644 index 00000000..ec032803 --- /dev/null +++ b/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_exception_test.dart @@ -0,0 +1,114 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:firebase_admin_sdk/src/remote_config/remote_config.dart'; +import 'package:test/test.dart'; + +void main() { + group('FirebaseRemoteConfigException', () { + test('exposes the structured code with the service prefix', () { + final exception = FirebaseRemoteConfigException( + RemoteConfigErrorCode.invalidArgument, + 'bad input', + ); + expect(exception.errorCode, RemoteConfigErrorCode.invalidArgument); + // Parent class composes code as "service/code", and the + // FirebaseServiceType name for Remote Config is "remote-config". + expect(exception.code, 'remote-config/invalid-argument'); + expect(exception.message, 'bad input'); + expect(exception.toString(), contains('remote-config/invalid-argument')); + }); + + test('maps known server status codes via remoteConfigErrorCodeMapping', () { + const expected = { + 'ABORTED': RemoteConfigErrorCode.aborted, + 'ALREADY_EXISTS': RemoteConfigErrorCode.alreadyExists, + 'INVALID_ARGUMENT': RemoteConfigErrorCode.invalidArgument, + 'INTERNAL': RemoteConfigErrorCode.internalError, + 'FAILED_PRECONDITION': RemoteConfigErrorCode.failedPrecondition, + 'NOT_FOUND': RemoteConfigErrorCode.notFound, + 'OUT_OF_RANGE': RemoteConfigErrorCode.outOfRange, + 'PERMISSION_DENIED': RemoteConfigErrorCode.permissionDenied, + 'RESOURCE_EXHAUSTED': RemoteConfigErrorCode.resourceExhausted, + 'UNAUTHENTICATED': RemoteConfigErrorCode.unauthenticated, + 'UNKNOWN': RemoteConfigErrorCode.unknownError, + }; + expect(remoteConfigErrorCodeMapping, expected); + }); + + test('fromServerError parses a structured JSON error', () { + const body = '''{ + "error": { + "code": 400, + "message": "Invalid template version.", + "status": "INVALID_ARGUMENT" + } + }'''; + final ex = FirebaseRemoteConfigException.fromServerError( + statusCode: 400, + body: body, + isJson: true, + ); + expect(ex.errorCode, RemoteConfigErrorCode.invalidArgument); + expect(ex.message, 'Invalid template version.'); + }); + + test( + 'fromServerError falls back to status when body lacks structured code', + () { + final byStatus = { + 400: RemoteConfigErrorCode.invalidArgument, + 401: RemoteConfigErrorCode.unauthenticated, + 403: RemoteConfigErrorCode.unauthenticated, + 404: RemoteConfigErrorCode.notFound, + 409: RemoteConfigErrorCode.alreadyExists, + 412: RemoteConfigErrorCode.failedPrecondition, + 429: RemoteConfigErrorCode.resourceExhausted, + 500: RemoteConfigErrorCode.internalError, + 599: RemoteConfigErrorCode.unknownError, + }; + for (final entry in byStatus.entries) { + final ex = FirebaseRemoteConfigException.fromServerError( + statusCode: entry.key, + body: 'not-json', + isJson: false, + ); + expect( + ex.errorCode, + entry.value, + reason: 'status ${entry.key} should map to ${entry.value}', + ); + } + }, + ); + + test('fromServerError handles JSON without an "error" key', () { + final ex = FirebaseRemoteConfigException.fromServerError( + statusCode: 400, + body: '{}', + isJson: true, + ); + expect(ex.errorCode, RemoteConfigErrorCode.invalidArgument); + }); + + test('fromServerError handles malformed JSON marked as JSON', () { + final ex = FirebaseRemoteConfigException.fromServerError( + statusCode: 500, + body: '{not valid json', + isJson: true, + ); + expect(ex.errorCode, RemoteConfigErrorCode.internalError); + }); + }); +} diff --git a/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_test.dart b/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_test.dart new file mode 100644 index 00000000..02c55001 --- /dev/null +++ b/packages/firebase_admin_sdk/test/unit/remote_config/remote_config_test.dart @@ -0,0 +1,381 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:firebase_admin_sdk/src/remote_config/remote_config.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/helpers.dart'; + +class _MockHttpClient extends Mock implements RemoteConfigHttpClient {} + +const _sampleTemplateBody = { + 'conditions': [], + 'parameters': { + 'flag': { + 'defaultValue': {'value': 'on'}, + }, + }, + 'parameterGroups': {}, +}; + +const _sampleServerTemplateBody = { + 'conditions': [], + 'parameters': { + 'flag': { + 'defaultValue': {'value': 'on'}, + }, + }, +}; + +const _sampleListVersionsBody = { + 'versions': [ + { + 'versionNumber': '7', + 'updateOrigin': 'CONSOLE', + 'updateType': 'INCREMENTAL_UPDATE', + 'description': 'tweak', + }, + {'versionNumber': '6'}, + ], + 'nextPageToken': 'tok-2', +}; + +void main() { + late _MockHttpClient httpClient; + late RemoteConfig rc; + + setUp(() { + httpClient = _MockHttpClient(); + final app = createApp( + name: 'rc-svc-${DateTime.now().microsecondsSinceEpoch}', + ); + rc = RemoteConfig.internal( + app, + requestHandler: RemoteConfigRequestHandler(app, httpClient: httpClient), + ); + }); + + group('getTemplate', () { + test('returns parsed template with etag from response header', () async { + when( + () => httpClient.getTemplate(), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-1')); + + final template = await rc.getTemplate(); + expect(template.etag, 'etag-1'); + expect(template.parameters['flag'], isA()); + expect( + (template.parameters['flag']!.defaultValue! as ExplicitParameterValue) + .value, + 'on', + ); + }); + + test('throws when response is missing the ETag header', () async { + when( + () => httpClient.getTemplate(), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: null)); + + await expectLater( + rc.getTemplate(), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + }); + + group('getTemplate(versionNumber)', () { + test('passes versionNumber through to the HTTP client', () async { + when( + () => httpClient.getTemplate(versionNumber: '42'), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-v')); + + final template = await rc.getTemplate('42'); + expect(template.etag, 'etag-v'); + verify(() => httpClient.getTemplate(versionNumber: '42')).called(1); + }); + + test('throws on non-integer versionNumber', () async { + await expectLater( + rc.getTemplate('abc'), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + }); + + group('publishTemplate', () { + test('sends If-Match: by default', () async { + when( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: any(named: 'etag'), + validateOnly: any(named: 'validateOnly'), + ), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-2')); + + final input = rc.createTemplateFromJson( + '{"etag":"etag-1","conditions":[],"parameters":{},"parameterGroups":{}}', + ); + final published = await rc.publishTemplate(input); + expect(published.etag, 'etag-2'); + + final captured = verify( + () => httpClient.publishTemplate( + body: captureAny(named: 'body'), + etag: captureAny(named: 'etag'), + validateOnly: captureAny(named: 'validateOnly'), + ), + ).captured; + expect(captured[1], 'etag-1'); + expect(captured[2], false); + expect( + (captured[0] as Map).containsKey('etag'), + isFalse, + ); + }); + + test('sends If-Match: * when force is true', () async { + when( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: any(named: 'etag'), + validateOnly: any(named: 'validateOnly'), + ), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-3')); + + final input = rc.createTemplateFromJson( + '{"etag":"old","conditions":[],"parameters":{},"parameterGroups":{}}', + ); + await rc.publishTemplate(input, force: true); + + final captured = verify( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: captureAny(named: 'etag'), + validateOnly: any(named: 'validateOnly'), + ), + ).captured; + expect(captured.single, '*'); + }); + + test( + 'strips output-only Version fields and keeps only description', + () async { + when( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: any(named: 'etag'), + validateOnly: any(named: 'validateOnly'), + ), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-4')); + + const json = '''{ + "etag": "old", + "conditions": [], + "parameters": {}, + "parameterGroups": {}, + "version": { + "versionNumber": "5", + "updateOrigin": "CONSOLE", + "updateType": "FORCED_UPDATE", + "description": "Manual edit" + } + }'''; + await rc.publishTemplate(rc.createTemplateFromJson(json)); + + final captured = + verify( + () => httpClient.publishTemplate( + body: captureAny(named: 'body'), + etag: any(named: 'etag'), + validateOnly: any(named: 'validateOnly'), + ), + ).captured.single + as Map; + // Only the user-input description survives; output-only fields are gone. + expect(captured['version'], { + 'description': 'Manual edit', + }); + }, + ); + }); + + group('validateTemplate', () { + test( + 'restores the original etag instead of the "-0"-suffixed response', + () async { + // The Remote Config backend returns the input etag with a "-0" suffix on + // a successful validation. The handler must overwrite that with the + // request etag so callers can keep using the template. + when( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: 'etag-1', + validateOnly: true, + ), + ).thenAnswer( + (_) async => (body: _sampleTemplateBody, etag: 'etag-1-0'), + ); + + final input = rc.createTemplateFromJson( + '{"etag":"etag-1","conditions":[],"parameters":{},"parameterGroups":{}}', + ); + final validated = await rc.validateTemplate(input); + expect(validated.etag, 'etag-1'); + }, + ); + + test('passes validateOnly: true', () async { + when( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: any(named: 'etag'), + validateOnly: true, + ), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-1-0')); + + final input = rc.createTemplateFromJson( + '{"etag":"etag-1","conditions":[],"parameters":{},"parameterGroups":{}}', + ); + await rc.validateTemplate(input); + + verify( + () => httpClient.publishTemplate( + body: any(named: 'body'), + etag: 'etag-1', + validateOnly: true, + ), + ).called(1); + }); + }); + + group('rollback', () { + test('forwards versionNumber to the HTTP client', () async { + when( + () => httpClient.rollback('5'), + ).thenAnswer((_) async => (body: _sampleTemplateBody, etag: 'etag-r')); + + final result = await rc.rollback('5'); + expect(result.etag, 'etag-r'); + verify(() => httpClient.rollback('5')).called(1); + }); + + test('throws on non-integer versionNumber', () async { + await expectLater( + rc.rollback('abc'), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.invalidArgument, + ), + ), + ); + }); + }); + + group('listVersions', () { + test('parses the response into a ListVersionsResult', () async { + when( + () => httpClient.listVersions( + pageSize: any(named: 'pageSize'), + pageToken: any(named: 'pageToken'), + endVersionNumber: any(named: 'endVersionNumber'), + startTime: any(named: 'startTime'), + endTime: any(named: 'endTime'), + ), + ).thenAnswer((_) async => _sampleListVersionsBody); + + final result = await rc.listVersions(); + expect(result.versions, hasLength(2)); + expect(result.versions[0].versionNumber, '7'); + expect(result.versions[0].updateOrigin, 'CONSOLE'); + expect(result.versions[0].description, 'tweak'); + expect(result.versions[1].versionNumber, '6'); + expect(result.nextPageToken, 'tok-2'); + }); + + test('passes options through verbatim', () async { + when( + () => httpClient.listVersions( + pageSize: 10, + pageToken: 'tok-1', + endVersionNumber: '20', + startTime: any(named: 'startTime'), + endTime: any(named: 'endTime'), + ), + ).thenAnswer( + (_) async => const {'versions': []}, + ); + + await rc.listVersions( + ListVersionsOptions( + pageSize: 10, + pageToken: 'tok-1', + endVersionNumber: '20', + ), + ); + + verify( + () => httpClient.listVersions( + pageSize: 10, + pageToken: 'tok-1', + endVersionNumber: '20', + startTime: null, + endTime: null, + ), + ).called(1); + }); + }); + + group('getServerTemplate', () { + test('loads, parses, and stamps the etag from the response', () async { + when(() => httpClient.getServerTemplate()).thenAnswer( + (_) async => (body: _sampleServerTemplateBody, etag: 'srv-1'), + ); + + final template = await rc.getServerTemplate(); + // Re-evaluate against an empty context — confirms the template made it + // into the cache and the parameter resolved via its default value. + final config = template.evaluate(); + expect(config.getString('flag'), 'on'); + }); + + test('initServerTemplate without a template throws on evaluate', () { + final template = rc.initServerTemplate(); + expect( + template.evaluate, + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + RemoteConfigErrorCode.failedPrecondition, + ), + ), + ); + }); + }); +}