diff --git a/lib/logger.dart b/lib/logger.dart index c914698..d2bfe57 100644 --- a/lib/logger.dart +++ b/lib/logger.dart @@ -49,4 +49,5 @@ /// - **stderr**: WARNING, ERROR, CRITICAL, ALERT, EMERGENCY library; -export 'src/logger/logger.dart'; +export 'src/logger/logger.dart' + hide createLogger, projectIdZoneKey, traceIdZoneKey; diff --git a/lib/src/common/environment.dart b/lib/src/common/environment.dart new file mode 100644 index 0000000..6b48b59 --- /dev/null +++ b/lib/src/common/environment.dart @@ -0,0 +1,128 @@ +// Copyright 2026 Firebase +// +// 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:convert'; +import 'dart:io'; + +import 'package:meta/meta.dart'; + +/// Provides unified access to environment variables, emulator checks, and +/// Google Cloud / Firebase configuration. +class FirebaseEnv { + FirebaseEnv() : environment = mockEnvironment ?? Platform.environment; + + @visibleForTesting + static Map? mockEnvironment; + + final Map environment; + + /// Whether running within a Firebase emulator environment. + bool get isEmulator { + // Explicit functions emulator flag + return environment['FUNCTIONS_EMULATOR'] == 'true' || + // Generic fallback: check if any common emulator hosts are configured + _emulatorHostKeys.any(environment.containsKey); + } + + /// Timezone setting. + String get tz => environment['TZ'] ?? 'UTC'; + + /// Whether debug mode is enabled. + bool get debugMode => environment['FIREBASE_DEBUG_MODE'] == 'true'; + + /// Whether to skip token verification (emulator only). + bool get skipTokenVerification => _getDebugFeature('skipTokenVerification'); + + /// Whether CORS is enabled (emulator only). + bool get enableCors => _getDebugFeature('enableCors'); + + bool _getDebugFeature(String key) { + if (environment['FIREBASE_DEBUG_FEATURES'] case final String json) { + try { + if (jsonDecode(json) case final Map m) { + return switch (m[key]) { + final bool value => value, + _ => false, + }; + } + } on FormatException { + // ignore + } + } + return false; + } + + /// Returns the current Firebase project ID. + /// + /// Checks standard environment variables in order: + /// 1. FIREBASE_PROJECT + /// 2. GCLOUD_PROJECT + /// 3. GOOGLE_CLOUD_PROJECT + /// 4. GCP_PROJECT + /// + /// If none are set, throws [StateError]. + String get projectId { + for (final option in _projectIdEnvKeyOptions) { + final value = environment[option]; + if (value != null && value.isNotEmpty) return value; + } + + throw StateError( + 'No project ID found in environment. Checked: ${_projectIdEnvKeyOptions.join(', ')}', + ); + } + + /// The port to listen on. + /// + /// Uses the [PORT] environment variable, defaulting to 8080. + int get port => int.tryParse(environment['PORT'] ?? '8080') ?? 8080; + + /// The name of the Cloud Run service. + /// + /// Uses the `K_SERVICE` environment variable. + /// + /// See https://cloud.google.com/run/docs/container-contract#env-vars + String? get kService => environment['K_SERVICE']; + + /// The name of the target function. + /// + /// Uses the `FUNCTION_TARGET` environment variable. + /// + /// See https://docs.cloud.google.com/run/docs/configuring/services/environment-variables#additional_reserved_environment_variables_when_deploying_functions + String? get functionTarget => environment['FUNCTION_TARGET']; + + /// Whether the functions control API is enabled. + /// + /// Uses the `FUNCTIONS_CONTROL_API` environment variable. + /// + /// This is part of the contract with `firebase-tools`. + bool get functionsControlApi => + environment['FUNCTIONS_CONTROL_API'] == 'true'; +} + +/// Common project ID environment variables checked in order. +const _projectIdEnvKeyOptions = [ + 'FIREBASE_PROJECT', + 'GCLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT', + 'GCP_PROJECT', +]; + +/// Common emulator host keys used to detect emulator environment. +const _emulatorHostKeys = [ + 'FIRESTORE_EMULATOR_HOST', + 'FIREBASE_AUTH_EMULATOR_HOST', + 'FIREBASE_DATABASE_EMULATOR_HOST', + 'FIREBASE_STORAGE_EMULATOR_HOST', +]; diff --git a/lib/src/firebase.dart b/lib/src/firebase.dart index c72e352..99c20d1 100644 --- a/lib/src/firebase.dart +++ b/lib/src/firebase.dart @@ -13,7 +13,6 @@ // limitations under the License. import 'dart:async'; -import 'dart:io'; import 'package:dart_firebase_admin/dart_firebase_admin.dart'; import 'package:google_cloud_firestore/google_cloud_firestore.dart' as gfs; @@ -22,6 +21,7 @@ import 'package:shelf/shelf.dart'; import 'alerts/alerts_namespace.dart'; import 'common/cloud_run_id.dart'; +import 'common/environment.dart'; import 'database/database_namespace.dart'; import 'eventarc/eventarc_namespace.dart'; import 'firestore/firestore_namespace.dart'; @@ -39,7 +39,7 @@ import 'test_lab/test_lab_namespace.dart'; /// /// Provides access to all function namespaces (https, pubsub, firestore, etc.). class Firebase { - Firebase() { + Firebase() : _env = FirebaseEnv() { _initializeAdminSDK(); } @@ -48,18 +48,7 @@ class Firebase { /// Initialize the Firebase Admin SDK void _initializeAdminSDK() { - // Get project ID from environment - final projectId = - Platform.environment['GCLOUD_PROJECT'] ?? - Platform.environment['GCP_PROJECT'] ?? - 'demo-test'; // Fallback for emulator - - // Check if running in emulator - final firestoreEmulatorHost = - Platform.environment['FIRESTORE_EMULATOR_HOST']; - final isEmulator = firestoreEmulatorHost != null; - - if (isEmulator) { + if (_env.isEmulator) { // TODO: Implement direct REST API calls to emulator // For now, we'll skip document fetching in emulator mode return; @@ -71,7 +60,7 @@ class Firebase { _adminApp = FirebaseApp.initializeApp( options: AppOptions( credential: Credential.fromApplicationDefaultCredentials(), - projectId: projectId, + projectId: _env.projectId, ), ); @@ -82,6 +71,8 @@ class Firebase { } } + final FirebaseEnv _env; + /// Get the Firestore instance gfs.Firestore? get firestoreAdmin => _firestoreInstance; @@ -260,3 +251,9 @@ abstract class FunctionsNamespace { const FunctionsNamespace(this.firebase); final Firebase firebase; } + +/// Internal extension to access private members of Firebase. +@internal +extension FirebaseInternal on Firebase { + FirebaseEnv get $env => _env; +} diff --git a/lib/src/https/https_namespace.dart b/lib/src/https/https_namespace.dart index 3f49f3c..87a7882 100644 --- a/lib/src/https/https_namespace.dart +++ b/lib/src/https/https_namespace.dart @@ -14,7 +14,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; @@ -26,20 +25,6 @@ import 'callable.dart'; import 'error.dart'; import 'options.dart'; -/// Checks if token verification should be skipped (emulator mode). -bool _shouldSkipTokenVerification() { - final debugFeatures = Platform.environment['FIREBASE_DEBUG_FEATURES']; - if (debugFeatures == null) { - return false; - } - try { - final features = jsonDecode(debugFeatures); - return features['skipTokenVerification'] as bool? ?? false; - } catch (_) { - return false; - } -} - /// HTTPS triggers namespace. /// /// Provides methods to define HTTP-triggered Cloud Functions. @@ -115,7 +100,7 @@ class HttpsNamespace extends FunctionsNamespace { } // Extract auth and app check tokens - final skipVerification = _shouldSkipTokenVerification(); + final skipVerification = firebase.$env.skipTokenVerification; final tokens = await checkTokens( request, skipTokenVerification: skipVerification, @@ -195,7 +180,7 @@ class HttpsNamespace extends FunctionsNamespace { final body = await request.json as Map?; // Extract auth and app check tokens - final skipVerification = _shouldSkipTokenVerification(); + final skipVerification = firebase.$env.skipTokenVerification; final tokens = await checkTokens( request, skipTokenVerification: skipVerification, diff --git a/lib/src/identity/identity_namespace.dart b/lib/src/identity/identity_namespace.dart index a3e20e3..e2bd0ce 100644 --- a/lib/src/identity/identity_namespace.dart +++ b/lib/src/identity/identity_namespace.dart @@ -17,7 +17,6 @@ library; import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; @@ -276,45 +275,24 @@ class IdentityNamespace extends FunctionsNamespace { /// certificates. In emulator mode (when FUNCTIONS_EMULATOR=true or /// skipTokenVerification debug feature is enabled), verification is skipped. Future> _decodeAndVerifyJwt(String jwt) async { - // Get environment configuration - final env = Platform.environment; - final isEmulator = env['FUNCTIONS_EMULATOR'] == 'true'; - final skipVerification = _shouldSkipTokenVerification(env); - // Get project ID - final projectId = - env['GCLOUD_PROJECT'] ?? - env['GCP_PROJECT'] ?? - env['FIREBASE_PROJECT'] ?? - 'demo-test'; + final projectId = firebase.$env.projectId; // Create verifier final verifier = AuthBlockingTokenVerifier( projectId: projectId, - isEmulator: isEmulator || skipVerification, + isEmulator: + firebase.$env.isEmulator || firebase.$env.skipTokenVerification, ); // Determine audience based on platform // Cloud Run uses "run.app", GCF v1 uses default - final kService = env['K_SERVICE']; // Cloud Run service name + final kService = firebase.$env.kService; // Cloud Run service name final audience = kService != null ? 'run.app' : null; return verifier.verifyToken(jwt, audience: audience); } - /// Checks if token verification should be skipped based on debug features. - bool _shouldSkipTokenVerification(Map env) { - final debugFeatures = env['FIREBASE_DEBUG_FEATURES']; - if (debugFeatures == null) return false; - - try { - final features = jsonDecode(debugFeatures) as Map; - return features['skipTokenVerification'] as bool? ?? false; - } on FormatException { - return false; - } - } - /// Validates the auth response for invalid claims. void _validateAuthResponse( AuthBlockingEventType eventType, diff --git a/lib/src/logger/logger.dart b/lib/src/logger/logger.dart index 6afe200..f8c3b96 100644 --- a/lib/src/logger/logger.dart +++ b/lib/src/logger/logger.dart @@ -16,6 +16,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; +import 'package:meta/meta.dart'; + /// Log severity levels for Cloud Logging. /// /// See [LogSeverity](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity). @@ -42,17 +44,6 @@ enum LogSeverity { /// of the logged entry. typedef LogEntry = Map; -/// Zone key for propagating trace IDs through async operations. -/// -/// Set this in a [Zone] to automatically include trace context in log entries: -/// ```dart -/// runZoned( -/// () { logger.info('traced!'); }, -/// zoneValues: {traceIdKey: 'abc123'}, -/// ); -/// ``` -const Symbol traceIdKey = #firebaseTraceId; - /// Removes circular references from an object graph for safe JSON /// serialization. /// @@ -128,6 +119,15 @@ bool _isStderrSeverity(String severity) => switch (severity) { _ => false, }; +/// Creates a new [Logger] instance. +/// +/// [stdoutWriter] and [stderrWriter] can be provided for testing. +@internal +Logger createLogger({ + void Function(String line)? stdoutWriter, + void Function(String line)? stderrWriter, +}) => Logger._(stdoutWriter: stdoutWriter, stderrWriter: stderrWriter); + /// Structured logger for Cloud Logging, compatible with the Firebase /// Functions Node.js SDK `logger` namespace. /// @@ -152,11 +152,11 @@ bool _isStderrSeverity(String severity) => switch (severity) { /// // Low-level structured entry /// logger.write({'severity': 'NOTICE', 'message': 'Custom', 'code': 42}); /// ``` -class Logger { +final class Logger { /// Creates a [Logger] instance. /// /// Custom [stdoutWriter] and [stderrWriter] can be provided for testing. - Logger({ + Logger._({ void Function(String line)? stdoutWriter, void Function(String line)? stderrWriter, }) : _stdoutWriter = stdoutWriter ?? _defaultStdoutWriter, @@ -171,17 +171,19 @@ class Logger { /// Writes a [LogEntry] to stdout or stderr depending on severity. /// /// The entry must contain a `severity` key. If a trace ID is available - /// in the current [Zone] (via [traceIdKey]), it is automatically added + /// in the current [Zone] (via [traceIdZoneKey]), it is automatically added /// to the entry. void write(LogEntry entry) { // Add trace context if available. - final traceId = Zone.current[traceIdKey] as String?; - if (traceId != null) { - final project = io.Platform.environment['GCLOUD_PROJECT']; - if (project != null) { - entry['logging.googleapis.com/trace'] = - 'projects/$project/traces/$traceId'; - } + + final projectId = Zone.current[projectIdZoneKey] as String?; + final traceId = Zone.current[traceIdZoneKey] as String?; + + if (projectId != null && traceId != null) { + assert(projectId.isNotEmpty, 'projectIdZoneKey value must not be empty'); + assert(traceId.isNotEmpty, 'traceIdZoneKey value must not be empty'); + entry['logging.googleapis.com/trace'] = + 'projects/$projectId/traces/$traceId'; } final sanitized = removeCircular(entry); @@ -269,4 +271,17 @@ LogEntry _entryFromArgs( /// logger.info('Hello'); /// logger.warn('Something is off', {'requestId': 'abc'}); /// ``` -final logger = Logger(); +final logger = Logger._(); + +/// Standard HTTP header used by +/// [Cloud Trace](https://cloud.google.com/trace/docs/setup). +@internal +const cloudTraceContextHeader = 'x-cloud-trace-context'; + +/// Zone key for propagating trace IDs. +@internal +final Object traceIdZoneKey = Object(); + +/// Zone key for propagating project ID. +@internal +final Object projectIdZoneKey = Object(); diff --git a/lib/src/server.dart b/lib/src/server.dart index e9254a5..92f141a 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -16,12 +16,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; - import 'package:stack_trace/stack_trace.dart' show Trace; import 'common/cloud_run_id.dart'; +import 'common/environment.dart'; import 'common/on_init.dart'; import 'firebase.dart'; import 'logger/logger.dart'; @@ -29,47 +30,6 @@ import 'logger/logger.dart'; /// Callback type for the user's function registration code. typedef FunctionsRunner = FutureOr Function(Firebase firebase); -/// Firebase emulator environment detection and configuration. -class EmulatorEnvironment { - EmulatorEnvironment(this.environment); - final Map environment; - - /// Whether running in the Firebase emulator. - bool get isEmulator => environment['FUNCTIONS_EMULATOR'] == 'true'; - - /// Timezone setting. - String get tz => environment['TZ'] ?? 'UTC'; - - /// Whether debug mode is enabled. - bool get debugMode => environment['FIREBASE_DEBUG_MODE'] == 'true'; - - /// Whether to skip token verification (emulator only). - bool get skipTokenVerification { - if (!environment.containsKey('FIREBASE_DEBUG_FEATURES')) { - return false; - } - try { - final features = jsonDecode(environment['FIREBASE_DEBUG_FEATURES']!); - return features['skipTokenVerification'] as bool? ?? false; - } on FormatException { - return false; - } - } - - /// Whether CORS is enabled (emulator only). - bool get enableCors { - if (!environment.containsKey('FIREBASE_DEBUG_FEATURES')) { - return false; - } - try { - final features = jsonDecode(environment['FIREBASE_DEBUG_FEATURES']!); - return features['enableCors'] as bool? ?? false; - } on FormatException { - return false; - } - } -} - /// Starts the Firebase Functions runtime. /// /// This is the main entry point for a Firebase Functions application. @@ -87,23 +47,37 @@ class EmulatorEnvironment { /// ``` Future fireUp(List args, FunctionsRunner runner) async { final firebase = Firebase(); - final emulatorEnv = EmulatorEnvironment(Platform.environment); + final env = firebase.$env; + final projectId = env.projectId; + + await runZoned(zoneValues: {projectIdZoneKey: projectId}, () async { + // Run user's function registration code + await runner(firebase); + + // Build request handler with middleware pipeline + final handler = const Pipeline() + .addMiddleware(_corsMiddleware(env)) + .addHandler((request) { + final traceId = extractTraceId( + request.headers[cloudTraceContextHeader], + ); - // Run user's function registration code - await runner(firebase); + if (traceId == null) { + return _routeRequest(request, firebase, env); + } - // Build request handler with middleware pipeline - final handler = const Pipeline() - .addMiddleware(_corsMiddleware(emulatorEnv)) - .addHandler((request) => _routeRequest(request, firebase, emulatorEnv)); + return runZoned(zoneValues: {traceIdZoneKey: traceId}, () { + return _routeRequest(request, firebase, env); + }); + }); - // Start HTTP server - final port = int.tryParse(Platform.environment['PORT'] ?? '8080') ?? 8080; - await shelf_io.serve(handler, InternetAddress.anyIPv4, port); + // Start HTTP server + await shelf_io.serve(handler, InternetAddress.anyIPv4, env.port); + }); } /// CORS middleware for emulator mode. -Middleware _corsMiddleware(EmulatorEnvironment env) => +Middleware _corsMiddleware(FirebaseEnv env) => (innerHandler) => (request) { // Handle preflight OPTIONS requests if (env.enableCors && request.method.toUpperCase() == 'OPTIONS') { @@ -136,7 +110,7 @@ Middleware _corsMiddleware(EmulatorEnvironment env) => FutureOr _routeRequest( Request request, Firebase firebase, - EmulatorEnvironment env, + FirebaseEnv env, ) { final functions = firebase.functions; final requestPath = request.url.path; @@ -152,15 +126,14 @@ FutureOr _routeRequest( return _handleQuitQuitQuit(request); } - if (requestPath == '__/functions.yaml' && - env.environment['FUNCTIONS_CONTROL_API'] == 'true') { + if (requestPath == '__/functions.yaml' && env.functionsControlApi) { // Manifest endpoint for function discovery return _handleFunctionsManifest(request, firebase); } // FUNCTION_TARGET mode (production): Serve only the specified function // This matches Node.js behavior where each Cloud Run service runs one function - final functionTarget = env.environment['FUNCTION_TARGET']; + final functionTarget = env.functionTarget; if (functionTarget != null && functionTarget.isNotEmpty) { return _routeToTargetFunction(request, firebase, env, functionTarget); } @@ -176,7 +149,7 @@ FutureOr _routeRequest( FutureOr _routeToTargetFunction( Request request, Firebase firebase, - EmulatorEnvironment env, + FirebaseEnv env, String functionTarget, ) { final functions = firebase.functions; @@ -700,3 +673,21 @@ bool _matchesRefPattern(String refPath, String pattern) { return true; } + +final _traceIdRegExp = RegExp(r'^[a-f0-9]{32}$', caseSensitive: false); + +/// Extracts the 32-character hexadecimal trace ID from an [x-cloud-trace-context] header. +/// +/// Expected format: `TRACE_ID/SPAN_ID;o=TRACE_TRUE` +@visibleForTesting +String? extractTraceId(String? header) { + if (header == null || header.isEmpty) return null; + final parts = header.split('/'); + if (parts.isNotEmpty) { + final traceId = parts[0]; + if (_traceIdRegExp.hasMatch(traceId)) { + return traceId; + } + } + return null; +} diff --git a/test/unit/error_logging_test.dart b/test/unit/error_logging_test.dart index 5ba62a6..75935f5 100644 --- a/test/unit/error_logging_test.dart +++ b/test/unit/error_logging_test.dart @@ -14,6 +14,7 @@ import 'dart:convert'; +import 'package:firebase_functions/src/common/environment.dart'; import 'package:firebase_functions/src/common/utilities.dart'; import 'package:firebase_functions/src/firebase.dart'; import 'package:firebase_functions/src/https/error.dart'; @@ -24,6 +25,10 @@ import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; void main() { + setUpAll(() { + FirebaseEnv.mockEnvironment = {'FIREBASE_PROJECT': 'demo-test'}; + }); + group('Error logging utilities', () { group('logInternalError', () { test('returns InternalError', () { diff --git a/test/unit/https_namespace_test.dart b/test/unit/https_namespace_test.dart index c568b38..3a2f26f 100644 --- a/test/unit/https_namespace_test.dart +++ b/test/unit/https_namespace_test.dart @@ -14,6 +14,7 @@ import 'dart:convert'; +import 'package:firebase_functions/src/common/environment.dart'; import 'package:firebase_functions/src/firebase.dart'; import 'package:firebase_functions/src/https/callable.dart'; import 'package:firebase_functions/src/https/error.dart'; @@ -32,6 +33,10 @@ FirebaseFunctionDeclaration? _findFunction(Firebase firebase, String name) { } void main() { + setUpAll(() { + FirebaseEnv.mockEnvironment = {'FIREBASE_PROJECT': 'demo-test'}; + }); + group('HttpsNamespace', () { late Firebase firebase; late HttpsNamespace https; diff --git a/test/unit/logger_test.dart b/test/unit/logger_test.dart index 18a1b44..7759667 100644 --- a/test/unit/logger_test.dart +++ b/test/unit/logger_test.dart @@ -27,7 +27,7 @@ void main() { setUp(() { lastStdout = ''; lastStderr = ''; - testLogger = Logger( + testLogger = createLogger( stdoutWriter: (line) => lastStdout = line, stderrWriter: (line) => lastStderr = line, ); @@ -293,7 +293,7 @@ void main() { group('trace context', () { test( - 'should add trace header when traceId is in Zone', + 'should add trace header when both projectId and traceId are in Zone', () { runZoned( () { @@ -306,13 +306,26 @@ void main() { }); }, zoneValues: { - traceIdKey: 'abc123', - // Simulate GCLOUD_PROJECT env var via a zone-aware override + projectIdZoneKey: 'test-project', + traceIdZoneKey: 'abc123', }, ); }, - skip: 'Requires GCLOUD_PROJECT env var to be set', ); + + test('should not add trace header when projectId is missing in Zone', () { + runZoned(() { + testLogger.write({'severity': 'INFO', 'message': 'traced'}); + expect(parseStdout(), {'severity': 'INFO', 'message': 'traced'}); + }, zoneValues: {traceIdZoneKey: 'abc123'}); + }); + + test('should not add trace header when traceId is missing in Zone', () { + runZoned(() { + testLogger.write({'severity': 'INFO', 'message': 'traced'}); + expect(parseStdout(), {'severity': 'INFO', 'message': 'traced'}); + }, zoneValues: {projectIdZoneKey: 'test-project'}); + }); }); }); diff --git a/test/unit/remote_config_test.dart b/test/unit/remote_config_test.dart index 90abf8c..4faa989 100644 --- a/test/unit/remote_config_test.dart +++ b/test/unit/remote_config_test.dart @@ -15,6 +15,7 @@ import 'dart:convert'; import 'package:firebase_functions/src/common/cloud_event.dart'; +import 'package:firebase_functions/src/common/environment.dart'; import 'package:firebase_functions/src/firebase.dart'; import 'package:firebase_functions/src/remote_config/config_update_data.dart'; import 'package:firebase_functions/src/remote_config/remote_config_namespace.dart'; @@ -71,6 +72,10 @@ Request _createRemoteConfigRequest({ } void main() { + setUpAll(() { + FirebaseEnv.mockEnvironment = {'FIREBASE_PROJECT': 'demo-test'}; + }); + group('RemoteConfigNamespace', () { late Firebase firebase; late RemoteConfigNamespace remoteConfig; diff --git a/test/unit/scheduler_namespace_test.dart b/test/unit/scheduler_namespace_test.dart index 2058a9b..229185e 100644 --- a/test/unit/scheduler_namespace_test.dart +++ b/test/unit/scheduler_namespace_test.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:firebase_functions/src/common/environment.dart'; import 'package:firebase_functions/src/common/options.dart'; import 'package:firebase_functions/src/firebase.dart'; import 'package:firebase_functions/src/scheduler/options.dart'; @@ -30,6 +31,10 @@ FirebaseFunctionDeclaration? _findFunction(Firebase firebase, String name) { } void main() { + setUpAll(() { + FirebaseEnv.mockEnvironment = {'FIREBASE_PROJECT': 'demo-test'}; + }); + group('SchedulerNamespace', () { late Firebase firebase; late SchedulerNamespace scheduler; diff --git a/test/unit/server_test.dart b/test/unit/server_test.dart new file mode 100644 index 0000000..71def4a --- /dev/null +++ b/test/unit/server_test.dart @@ -0,0 +1,53 @@ +// Copyright 2026 Firebase +// +// 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_functions/src/server.dart'; +import 'package:test/test.dart'; + +void main() { + group('server', () { + group('extractTraceId', () { + test('extracts valid trace ID with span and option', () { + const header = '4bf92f3577b34da6a3ce929d0e0e4736/12345;o=1'; + expect(extractTraceId(header), '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + test('extracts valid trace ID with uppercase hex', () { + const header = '4BF92F3577B34DA6A3CE929D0E0E4736/12345;o=1'; + expect(extractTraceId(header), '4BF92F3577B34DA6A3CE929D0E0E4736'); + }); + + test('extracts valid trace ID without span or option', () { + const header = '1234567890abcdef1234567890abcdef'; + expect(extractTraceId(header), '1234567890abcdef1234567890abcdef'); + }); + + test('handles null and empty', () { + expect(extractTraceId(null), isNull); + expect(extractTraceId(''), isNull); + }); + + test('rejects malformed traces', () { + // Too short + expect(extractTraceId('1234/567;o=1'), isNull); + + // Too long + expect(extractTraceId('1234567890abcdef1234567890abcdef0/5'), isNull); + + // Invalid hex + expect(extractTraceId('1234567890xyzdef1234567890abcdef/5'), isNull); + }); + }); + }); +} diff --git a/test/unit/storage_test.dart b/test/unit/storage_test.dart index 4bc7f7b..50913fa 100644 --- a/test/unit/storage_test.dart +++ b/test/unit/storage_test.dart @@ -14,6 +14,7 @@ import 'dart:convert'; +import 'package:firebase_functions/src/common/environment.dart'; import 'package:firebase_functions/src/firebase.dart'; import 'package:firebase_functions/src/storage/storage_event.dart'; import 'package:firebase_functions/src/storage/storage_namespace.dart'; @@ -72,6 +73,10 @@ Request _createStorageRequest({ } void main() { + setUpAll(() { + FirebaseEnv.mockEnvironment = {'FIREBASE_PROJECT': 'demo-test'}; + }); + group('StorageNamespace', () { late Firebase firebase; late StorageNamespace storage;