diff --git a/packages/dart_firebase_admin/lib/src/app_check/token_generator.dart b/packages/dart_firebase_admin/lib/src/app_check/token_generator.dart index dcb2906d..9aa80ffb 100644 --- a/packages/dart_firebase_admin/lib/src/app_check/token_generator.dart +++ b/packages/dart_firebase_admin/lib/src/app_check/token_generator.dart @@ -43,6 +43,7 @@ class AppCheckTokenGenerator { 'aud': firebaseAppCheckAudience, 'exp': iat + (oneMinuteInSeconds * 5), 'iat': iat, + if (options?.ttlMillis case final ttl?) 'ttl': '${ttl.inSeconds}s', }; final token = '${_encodeSegment(header)}.${_encodeSegment(body)}'; diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart b/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart index 951b2aac..482e508c 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart @@ -218,7 +218,7 @@ abstract class _AbstractAuthRequestHandler { options, ignoreMissingFields: true, ); - final updateMask = generateUpdateMask(request); + final updateMask = generateUpdateMask(request?.toJson()); final response = await _httpClient.updateOAuthIdpConfig( request ?? auth2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig(), @@ -252,7 +252,7 @@ abstract class _AbstractAuthRequestHandler { options, ignoreMissingFields: true, ); - final updateMask = generateUpdateMask(request); + final updateMask = generateUpdateMask(request?.toJson()); final response = await _httpClient.updateInboundSamlConfig( request ?? auth2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig(), providerId, diff --git a/packages/dart_firebase_admin/lib/src/storage/storage.dart b/packages/dart_firebase_admin/lib/src/storage/storage.dart index e558eca0..ad78a30c 100644 --- a/packages/dart_firebase_admin/lib/src/storage/storage.dart +++ b/packages/dart_firebase_admin/lib/src/storage/storage.dart @@ -21,6 +21,10 @@ class Storage implements FirebaseService { ); } setNativeEnvironmentVariable('STORAGE_EMULATOR_HOST', emulatorHost); + } else { + // Ensure no emulator host leaks into production GCS client from a + // previous emulator-mode Storage instance in the same process. + unsetNativeEnvironmentVariable('STORAGE_EMULATOR_HOST'); } _delegate = gcs.Storage(); } diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.dart b/packages/dart_firebase_admin/lib/src/utils/jwt.dart index 680c8b7d..3a52f74c 100644 --- a/packages/dart_firebase_admin/lib/src/utils/jwt.dart +++ b/packages/dart_firebase_admin/lib/src/utils/jwt.dart @@ -192,7 +192,10 @@ class PublicKeySignatureVerifier implements SignatureVerifier { } try { - JWT.verify(token, key); + // Firebase session cookies omit the `typ` header claim, which + // dart_jsonwebtoken 3.x rejects by default. Disable the check here; + // signature, issuer, audience, and expiry are validated separately. + JWT.verify(token, key, checkHeaderType: false); } catch (e, stackTrace) { Error.throwWithStackTrace( JwtException( diff --git a/packages/dart_firebase_admin/lib/src/utils/native_environment.dart b/packages/dart_firebase_admin/lib/src/utils/native_environment.dart index 2e123298..9eef7fea 100644 --- a/packages/dart_firebase_admin/lib/src/utils/native_environment.dart +++ b/packages/dart_firebase_admin/lib/src/utils/native_environment.dart @@ -12,6 +12,11 @@ final int Function(Pointer, Pointer, int) _setenv = int Function(Pointer, Pointer, int) >('setenv'); +final int Function(Pointer) _unsetenv = DynamicLibrary.process() + .lookupFunction), int Function(Pointer)>( + 'unsetenv', + ); + final int Function(Pointer, Pointer) _setEnvironmentVariableW = DynamicLibrary.open('kernel32.dll').lookupFunction< Int32 Function(Pointer, Pointer), @@ -44,3 +49,19 @@ void setNativeEnvironmentVariable(String name, String value) { }); } } + +@internal +void unsetNativeEnvironmentVariable(String name) { + if (Platform.isWindows) { + using((arena) { + final namePtr = name.toNativeUtf16(allocator: arena); + // Passing NULL as the second argument deletes the variable on Windows + _setEnvironmentVariableW(namePtr, nullptr); + }); + } else { + using((arena) { + final namePtr = name.toNativeUtf8(allocator: arena); + _unsetenv(namePtr); + }); + } +} diff --git a/packages/dart_firebase_admin/test/integration/app_check/app_check_test.dart b/packages/dart_firebase_admin/test/integration/app_check/app_check_test.dart new file mode 100644 index 00000000..a6f3f8b7 --- /dev/null +++ b/packages/dart_firebase_admin/test/integration/app_check/app_check_test.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:dart_firebase_admin/app_check.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/helpers.dart'; + +const _testAppId = '1:559949546715:android:13025aec6cc3243d0ab8fe'; + +void main() { + group( + 'AppCheck (Production)', + () { + group('createToken()', () { + test('returns a token with a non-empty JWT string', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + final token = await appCheck.createToken(_testAppId); + + expect(token.token, isNotEmpty); + // A JWT has exactly two dots + expect(token.token.split('.').length, equals(3)); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + + test('returns ttlMillis within the valid range (30min–7days)', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + final token = await appCheck.createToken(_testAppId); + + expect( + token.ttlMillis, + greaterThanOrEqualTo( + const Duration(minutes: 30).inMilliseconds, + ), + ); + expect( + token.ttlMillis, + lessThanOrEqualTo(const Duration(days: 7).inMilliseconds), + ); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + + test('honours a custom ttlMillis option', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + const customTtl = Duration(hours: 2); + final token = await appCheck.createToken( + _testAppId, + AppCheckTokenOptions(ttlMillis: customTtl), + ); + + expect(token.token, isNotEmpty); + // Server rounds to the nearest second; allow ±1 second tolerance. + expect(token.ttlMillis, closeTo(customTtl.inMilliseconds, 1000)); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + }); + + group('verifyToken()', () { + test('returns decoded token with correct claims structure', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + final token = await appCheck.createToken(_testAppId); + final result = await appCheck.verifyToken(token.token); + + final decoded = result.token; + expect(result.appId, equals(_testAppId)); + expect(decoded.sub, equals(_testAppId)); + expect( + decoded.iss, + startsWith('https://firebaseappcheck.googleapis.com/'), + ); + expect(decoded.aud, isNotEmpty); + expect(decoded.exp, greaterThan(decoded.iat)); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + + test('sets alreadyConsumed to null when consume option is not set', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + final token = await appCheck.createToken(_testAppId); + final result = await appCheck.verifyToken(token.token); + + expect(result.alreadyConsumed, isNull); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + + test( + 'sets alreadyConsumed to false on first consume, true on second', + () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + final token = await appCheck.createToken(_testAppId); + + final first = await appCheck.verifyToken( + token.token, + VerifyAppCheckTokenOptions()..consume = true, + ); + expect(first.alreadyConsumed, isFalse); + + final second = await appCheck.verifyToken( + token.token, + VerifyAppCheckTokenOptions()..consume = true, + ); + expect(second.alreadyConsumed, isTrue); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }, + ); + + test('throws FirebaseAppCheckException for an invalid token', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + await expectLater( + () => appCheck.verifyToken('invalid.token.value'), + throwsA(isA()), + ); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + + test('throws FirebaseAppCheckException for a tampered token', () { + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck.internal(app); + + try { + final token = await appCheck.createToken(_testAppId); + // Corrupt the signature portion of the JWT. + final parts = token.token.split('.'); + final tampered = '${parts[0]}.${parts[1]}.invalidsignature'; + + await expectLater( + () => appCheck.verifyToken(tampered), + throwsA(isA()), + ); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }); + }); + }, + skip: hasProdEnv + ? false + : 'Requires GOOGLE_APPLICATION_CREDENTIALS and RUN_PROD_TESTS=true', + ); +} diff --git a/packages/dart_firebase_admin/test/integration/auth/auth_prod_test.dart b/packages/dart_firebase_admin/test/integration/auth/auth_prod_test.dart index cd3cd2a4..d23773da 100644 --- a/packages/dart_firebase_admin/test/integration/auth/auth_prod_test.dart +++ b/packages/dart_firebase_admin/test/integration/auth/auth_prod_test.dart @@ -520,4 +520,347 @@ void main() { : 'Provider configs require GCIP (not available in emulator)', ); }); + + group('getProviderConfig (Production)', () { + test( + 'returns the config for an existing OIDC provider', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + final providerId = + 'oidc.get-${DateTime.now().millisecondsSinceEpoch}'; + + try { + await testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: providerId, + displayName: 'Get Test Provider', + enabled: true, + clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', + issuer: 'https://oidc.example.com/issuer', + ), + ); + + final config = await testAuth.getProviderConfig(providerId); + + expect(config, isA()); + expect(config.providerId, equals(providerId)); + expect(config.displayName, equals('Get Test Provider')); + expect(config.enabled, isTrue); + } finally { + await testAuth.deleteProviderConfig(providerId).catchError((_) {}); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + + test( + 'throws FirebaseAuthAdminException for a non-existent provider', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + + try { + await expectLater( + () => testAuth.getProviderConfig('oidc.does-not-exist'), + throwsA(isA()), + ); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + }); + + group('updateProviderConfig (Production)', () { + test( + 'updates an OIDC provider config', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + final providerId = + 'oidc.update-${DateTime.now().millisecondsSinceEpoch}'; + + try { + await testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: providerId, + displayName: 'Original Name', + enabled: true, + clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', + issuer: 'https://oidc.example.com/issuer', + ), + ); + + final updated = await testAuth.updateProviderConfig( + providerId, + OIDCUpdateAuthProviderRequest( + displayName: 'Updated Name', + enabled: false, + ), + ); + + expect(updated, isA()); + expect(updated.displayName, equals('Updated Name')); + expect(updated.enabled, isFalse); + } finally { + await testAuth.deleteProviderConfig(providerId).catchError((_) {}); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + + test( + 'updates a SAML provider config', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + final providerId = + 'saml.update-${DateTime.now().millisecondsSinceEpoch}'; + + try { + await testAuth.createProviderConfig( + SAMLAuthProviderConfig( + providerId: providerId, + displayName: 'Original Name', + enabled: true, + idpEntityId: 'TEST_IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['TEST_CERT'], + rpEntityId: 'TEST_RP_ENTITY_ID', + callbackURL: + 'https://project-id.firebaseapp.com/__/auth/handler', + ), + ); + + final updated = await testAuth.updateProviderConfig( + providerId, + SAMLUpdateAuthProviderRequest( + displayName: 'Updated Name', + enabled: false, + ), + ); + + expect(updated, isA()); + expect(updated.displayName, equals('Updated Name')); + expect(updated.enabled, isFalse); + } finally { + await testAuth.deleteProviderConfig(providerId).catchError((_) {}); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + }); + + group('listProviderConfigs (Production)', () { + test( + 'lists OIDC provider configs including one just created', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + final providerId = + 'oidc.list-${DateTime.now().millisecondsSinceEpoch}'; + + try { + await testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: providerId, + displayName: 'List Test Provider', + enabled: true, + clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', + issuer: 'https://oidc.example.com/issuer', + ), + ); + + final result = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.oidc(), + ); + + expect(result.providerConfigs, isNotEmpty); + expect( + result.providerConfigs.map((c) => c.providerId), + contains(providerId), + ); + expect( + result.providerConfigs, + everyElement(isA()), + ); + } finally { + await testAuth.deleteProviderConfig(providerId).catchError((_) {}); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + + test( + 'lists SAML provider configs including one just created', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + final providerId = + 'saml.list-${DateTime.now().millisecondsSinceEpoch}'; + + try { + await testAuth.createProviderConfig( + SAMLAuthProviderConfig( + providerId: providerId, + displayName: 'List Test Provider', + enabled: true, + idpEntityId: 'TEST_IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['TEST_CERT'], + rpEntityId: 'TEST_RP_ENTITY_ID', + callbackURL: + 'https://project-id.firebaseapp.com/__/auth/handler', + ), + ); + + final result = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.saml(), + ); + + expect(result.providerConfigs, isNotEmpty); + expect( + result.providerConfigs.map((c) => c.providerId), + contains(providerId), + ); + expect( + result.providerConfigs, + everyElement(isA()), + ); + } finally { + await testAuth.deleteProviderConfig(providerId).catchError((_) {}); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + + test( + 'supports pagination with maxResults and pageToken', + () { + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth.internal(app); + final ts = DateTime.now().millisecondsSinceEpoch; + final providerId1 = 'oidc.page1-$ts'; + final providerId2 = 'oidc.page2-$ts'; + + try { + await Future.wait([ + testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: providerId1, + displayName: 'Page Test 1', + enabled: true, + clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', + issuer: 'https://oidc.example.com/issuer', + ), + ), + testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: providerId2, + displayName: 'Page Test 2', + enabled: true, + clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', + issuer: 'https://oidc.example.com/issuer', + ), + ), + ]); + + final firstPage = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.oidc(maxResults: 1), + ); + + expect(firstPage.providerConfigs, hasLength(1)); + expect(firstPage.pageToken, isNotNull); + + final secondPage = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.oidc( + maxResults: 1, + pageToken: firstPage.pageToken, + ), + ); + + expect(secondPage.providerConfigs, hasLength(greaterThan(0))); + expect( + secondPage.providerConfigs.first.providerId, + isNot(equals(firstPage.providerConfigs.first.providerId)), + ); + } finally { + await Future.wait([ + testAuth.deleteProviderConfig(providerId1).catchError((_) {}), + testAuth.deleteProviderConfig(providerId2).catchError((_) {}), + ]); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasProdEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + }); } diff --git a/packages/dart_firebase_admin/test/integration/auth/auth_test.dart b/packages/dart_firebase_admin/test/integration/auth/auth_test.dart index 052118b5..efe70021 100644 --- a/packages/dart_firebase_admin/test/integration/auth/auth_test.dart +++ b/packages/dart_firebase_admin/test/integration/auth/auth_test.dart @@ -14,8 +14,10 @@ // FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 dart test test/auth/integration_test.dart import 'dart:convert'; +import 'dart:io'; import 'package:dart_firebase_admin/auth.dart'; +import 'package:dart_firebase_admin/src/app.dart'; import 'package:http/http.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -584,4 +586,90 @@ void main() { } }); }); + + group('getUser', () { + test('returns correct user record for an existing uid', () async { + final created = await auth.createUser(CreateRequest(uid: _uid.v4())); + + final user = await auth.getUser(created.uid); + + expect(user.uid, equals(created.uid)); + }); + + test('throws FirebaseAuthAdminException for a non-existent uid', () async { + await expectLater( + () => auth.getUser('uid-that-does-not-exist-${_uid.v4()}'), + throwsA(isA()), + ); + }); + }); + + group('verifyIdToken', () { + // Signs up an anonymous user via the emulator REST API and returns the + // uid and ID token. Does not require service account credentials. + Future<({String uid, String idToken})> signUpAnonymously() async { + final emulatorHost = + Platform.environment[Environment.firebaseAuthEmulatorHost] ?? + 'localhost:9099'; + // The Auth emulator does not validate API keys, so any non-empty string + // works. 'emulator-fake-api-key' is a conventional placeholder used in + // Firebase tooling for emulator-only requests. + const emulatorApiKey = 'emulator-fake-api-key'; + final url = Uri.parse( + 'http://$emulatorHost/identitytoolkit.googleapis.com/v1/accounts:signUp?key=$emulatorApiKey', + ); + final response = await post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'returnSecureToken': true}), + ); + final body = jsonDecode(response.body) as Map; + return ( + uid: body['localId'] as String, + idToken: body['idToken'] as String, + ); + } + + test( + 'returns decoded token with correct fields for a valid token', + () async { + final (:uid, :idToken) = await signUpAnonymously(); + + final decoded = await auth.verifyIdToken(idToken); + + expect(decoded.uid, equals(uid)); + expect(decoded.sub, equals(uid)); + expect(decoded.aud, equals(projectId)); + expect(decoded.iss, contains('securetoken.google.com')); + expect(decoded.exp, greaterThan(decoded.iat)); + }, + ); + + test('throws FirebaseAuthAdminException for an invalid token', () async { + await expectLater( + () => auth.verifyIdToken('invalid.token.value'), + throwsA(isA()), + ); + }); + + test('verifies valid token with checkRevoked set to true', () async { + final (:uid, :idToken) = await signUpAnonymously(); + + final decoded = await auth.verifyIdToken(idToken, checkRevoked: true); + expect(decoded.uid, equals(uid)); + }); + + test('throws when token is revoked and checkRevoked is true', () async { + final (:uid, :idToken) = await signUpAnonymously(); + + // Wait so the revocation timestamp is strictly after the token's iat. + await Future.delayed(const Duration(seconds: 1)); + await auth.revokeRefreshTokens(uid); + + await expectLater( + () => auth.verifyIdToken(idToken, checkRevoked: true), + throwsA(isA()), + ); + }); + }); } diff --git a/packages/dart_firebase_admin/test/integration/functions/functions_test.dart b/packages/dart_firebase_admin/test/integration/functions/functions_test.dart index 4672fffa..24013df7 100644 --- a/packages/dart_firebase_admin/test/integration/functions/functions_test.dart +++ b/packages/dart_firebase_admin/test/integration/functions/functions_test.dart @@ -66,6 +66,19 @@ void main() { 'message': 'Task with deadline', }, TaskOptions(dispatchDeadlineSeconds: 300)); }); + + test('enqueues a task with experimental URI', () async { + final queue = functions.taskQueue('helloWorld'); + + await queue.enqueue( + {'message': 'Task with custom URI'}, + TaskOptions( + experimental: TaskOptionsExperimental( + uri: 'https://custom.example.com/handler', + ), + ), + ); + }); }); group('delete', () { diff --git a/packages/dart_firebase_admin/test/integration/messaging/messaging_test.dart b/packages/dart_firebase_admin/test/integration/messaging/messaging_test.dart index 514ba933..7c3767a2 100644 --- a/packages/dart_firebase_admin/test/integration/messaging/messaging_test.dart +++ b/packages/dart_firebase_admin/test/integration/messaging/messaging_test.dart @@ -53,6 +53,21 @@ void main() { expect(messageId, matches(RegExp(r'^projects/.*/messages/.*$'))); }); + test('send(ConditionMessage, dryRun) returns a message ID', () async { + final messageId = await messaging.send( + ConditionMessage( + condition: "'foo-bar' in topics || 'baz' in topics", + notification: Notification( + title: 'Integration Test', + body: 'Testing ConditionMessage', + ), + ), + dryRun: true, + ); + + expect(messageId, matches(RegExp(r'^projects/.*/messages/.*$'))); + }); + test('sendEach()', () async { final messages = [ TopicMessage( @@ -81,6 +96,36 @@ void main() { } }); + test( + 'sendEach() with mixed message types including ConditionMessage', + () async { + final messages = [ + TopicMessage( + topic: 'foo-bar', + notification: Notification(title: 'Topic'), + ), + ConditionMessage( + condition: "'foo-bar' in topics || 'baz' in topics", + notification: Notification(title: 'Condition'), + ), + ]; + + final response = await messaging.sendEach(messages, dryRun: true); + + expect(response.responses.length, equals(2)); + expect(response.successCount, equals(2)); + expect(response.failureCount, equals(0)); + + for (final resp in response.responses) { + expect(resp.success, isTrue); + expect( + resp.messageId, + matches(RegExp(r'^projects/.*/messages/.*$')), + ); + } + }, + ); + test('sendEach() validates empty messages list', () async { await expectLater( () => messaging.sendEach([]), diff --git a/packages/dart_firebase_admin/test/unit/app_check/app_check_test.dart b/packages/dart_firebase_admin/test/unit/app_check/app_check_test.dart index cab2e28d..2f489b1f 100644 --- a/packages/dart_firebase_admin/test/unit/app_check/app_check_test.dart +++ b/packages/dart_firebase_admin/test/unit/app_check/app_check_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:dart_firebase_admin/app_check.dart'; import 'package:dart_firebase_admin/src/app.dart'; import 'package:dart_firebase_admin/src/app_check/app_check.dart'; @@ -8,7 +7,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; -import '../../fixtures/helpers.dart'; import '../../fixtures/mock.dart'; import '../../fixtures/mock_service_account.dart'; @@ -324,100 +322,5 @@ void main() { } }); }); - - group('e2e', () { - test( - skip: hasProdEnv ? false : 'Requires GOOGLE_APPLICATION_CREDENTIALS', - 'should create and verify token', - () { - return runZoned(() async { - final appName = - 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; - final app = FirebaseApp.initializeApp(name: appName); - final appCheck = AppCheck.internal(app); - - try { - final token = await appCheck.createToken( - '1:559949546715:android:13025aec6cc3243d0ab8fe', - ); - - expect(token.token, isNotEmpty); - expect(token.ttlMillis, greaterThan(0)); - - final result = await appCheck.verifyToken(token.token); - - expect(result.appId, isNotEmpty); - expect(result.token, isNotNull); - expect(result.alreadyConsumed, isNull); - } finally { - await app.close(); - } - }, zoneValues: {envSymbol: prodEnv()}); - }, - ); - - test( - skip: hasProdEnv ? false : 'Requires GOOGLE_APPLICATION_CREDENTIALS', - 'should create token with custom ttl', - () { - return runZoned(() async { - final appName = - 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; - final app = FirebaseApp.initializeApp(name: appName); - final appCheck = AppCheck.internal(app); - - try { - final token = await appCheck.createToken( - '1:559949546715:android:13025aec6cc3243d0ab8fe', - AppCheckTokenOptions(ttlMillis: const Duration(hours: 2)), - ); - - expect(token.token, isNotEmpty); - expect(token.ttlMillis, greaterThan(0)); - } finally { - await app.close(); - } - }, zoneValues: {envSymbol: prodEnv()}); - }, - ); - - test( - skip: hasProdEnv ? false : 'Requires GOOGLE_APPLICATION_CREDENTIALS', - 'should verify token with consume option', - () { - return runZoned(() async { - final appName = - 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; - final app = FirebaseApp.initializeApp(name: appName); - final appCheck = AppCheck.internal(app); - - try { - final token = await appCheck.createToken( - '1:559949546715:android:13025aec6cc3243d0ab8fe', - ); - - final result = await appCheck.verifyToken( - token.token, - VerifyAppCheckTokenOptions()..consume = true, - ); - - expect(result.appId, isNotEmpty); - expect(result.token, isNotNull); - expect(result.alreadyConsumed, equals(false)); - - // Verify same token again - should be marked as consumed - final result2 = await appCheck.verifyToken( - token.token, - VerifyAppCheckTokenOptions()..consume = true, - ); - - expect(result2.alreadyConsumed, equals(true)); - } finally { - await app.close(); - } - }, zoneValues: {envSymbol: prodEnv()}); - }, - ); - }); }); } diff --git a/packages/dart_firebase_admin/test/unit/functions/functions_test.dart b/packages/dart_firebase_admin/test/unit/functions/functions_test.dart index a58f1eef..b0a616e1 100644 --- a/packages/dart_firebase_admin/test/unit/functions/functions_test.dart +++ b/packages/dart_firebase_admin/test/unit/functions/functions_test.dart @@ -126,6 +126,23 @@ void main() { functions = createFunctionsWithMockHandler(mockHandler); }); + group('app property', () { + test('returns the app passed to the constructor', () { + final appName = + 'functions-app-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp( + name: appName, + options: const AppOptions(projectId: projectId), + ); + addTearDown(() => FirebaseApp.deleteApp(app)); + + final f = Functions.internal(app); + + expect(identical(f.app, app), isTrue); + expect(f.app.name, equals(appName)); + }); + }); + group('taskQueue', () { test('creates TaskQueue with function name', () { final queue = functions.taskQueue('helloWorld'); @@ -595,6 +612,52 @@ void main() { ); }); }); + + group('experimental.uri', () { + test('throws on invalid URI', () { + expect( + () => TaskOptionsExperimental(uri: 'not-a-url'), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + FunctionsClientErrorCode.invalidArgument, + ), + ), + ); + }); + + test('throws on URI with no scheme', () { + expect( + () => TaskOptionsExperimental(uri: 'example.com/path'), + throwsA(isA()), + ); + }); + + test('accepts a valid HTTPS URI', () { + expect( + () => TaskOptionsExperimental(uri: 'https://example.com/handler'), + returnsNormally, + ); + }); + + test('accepts a valid HTTP URI', () { + expect( + () => TaskOptionsExperimental(uri: 'http://localhost:8080/handler'), + returnsNormally, + ); + }); + + test('accepts null URI', () { + expect(TaskOptionsExperimental.new, returnsNormally); + }); + + test('stores the URI value', () { + const uri = 'https://example.com/handler'; + final opts = TaskOptionsExperimental(uri: uri); + expect(opts.uri, equals(uri)); + }); + }); }); // =========================================================================== @@ -1103,6 +1166,43 @@ void main() { await app.close(); } }); + + test('uses experimental.uri as the httpRequest URL', () async { + Map? capturedTaskBody; + const customUri = 'https://custom.example.com/my-handler'; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'experimental-uri-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions.internal(app); + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions( + experimental: TaskOptionsExperimental(uri: customUri), + ); + await queue.enqueue({'data': 'test'}, options); + + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + expect(httpRequest['url'], equals(customUri)); + } finally { + await app.close(); + } + }); }); // =========================================================================== diff --git a/packages/dart_firebase_admin/test/unit/messaging/messaging_test.dart b/packages/dart_firebase_admin/test/unit/messaging/messaging_test.dart index 0f64c2fb..e6556577 100644 --- a/packages/dart_firebase_admin/test/unit/messaging/messaging_test.dart +++ b/packages/dart_firebase_admin/test/unit/messaging/messaging_test.dart @@ -187,6 +187,30 @@ void main() { ); }); + test( + 'sends a ConditionMessage with condition set on the request', + () async { + when( + () => messages.send(any(), any()), + ).thenAnswer((_) => Future.value(fmc1.Message(name: 'test'))); + + const condition = "'foo-bar' in topics || 'baz' in topics"; + final result = await messaging.send( + ConditionMessage(condition: condition), + ); + + expect(result, 'test'); + + final capture = verify(() => messages.send(captureAny(), captureAny())) + ..called(1); + + final request = capture.captured.first as fmc1.SendMessageRequest; + expect(request.message?.condition, condition); + expect(request.message?.topic, isNull); + expect(request.message?.token, isNull); + }, + ); + test('dryRun', () async { when( () => messages.send(any(), any()), diff --git a/packages/google_cloud_firestore/test/unit/firestore_test.dart b/packages/google_cloud_firestore/test/unit/firestore_test.dart index f035990d..110032e8 100644 --- a/packages/google_cloud_firestore/test/unit/firestore_test.dart +++ b/packages/google_cloud_firestore/test/unit/firestore_test.dart @@ -1,6 +1,10 @@ import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/src/firestore_http_client.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; +class MockFirestoreHttpClient extends Mock implements FirestoreHttpClient {} + void main() { group('Firestore', () { test('toJSON() returns projectId from settings', () { @@ -204,5 +208,22 @@ void main() { expect(bundle.bundleId, 'my-bundle'); }); }); + + group('terminate()', () { + test('calls close() on the HTTP client', () async { + final mockClient = MockFirestoreHttpClient(); + when(mockClient.close).thenAnswer((_) async {}); + when(() => mockClient.cachedProjectId).thenReturn('test'); + + final firestore = Firestore.internal( + settings: const Settings(projectId: 'test'), + client: mockClient, + ); + + await firestore.terminate(); + + verify(mockClient.close).called(1); + }); + }); }); }