From 8fe54d385e40ebbbbba48706520711a1a28f27af Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 23 Mar 2026 15:55:34 +0100 Subject: [PATCH 01/11] test: add unit tests for AppCheck token creation and verification --- .../integration/app_check/app_check_test.dart | 206 ++++++++++++++++++ .../test/unit/app_check/app_check_test.dart | 97 --------- 2 files changed, 206 insertions(+), 97 deletions(-) create mode 100644 packages/dart_firebase_admin/test/integration/app_check/app_check_test.dart 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/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()}); - }, - ); - }); }); } From e4bfa6293bd49fc4b2877c55d977f24daa6e8143 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 23 Mar 2026 15:55:56 +0100 Subject: [PATCH 02/11] test: add unit tests for OIDC and SAML provider config operations --- .../test/integration/auth/auth_prod_test.dart | 338 ++++++++++++++++++ .../test/integration/auth/auth_test.dart | 84 +++++ 2 files changed, 422 insertions(+) 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..5c0d0e18 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,342 @@ 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', + 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', + 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', + 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', + issuer: 'https://oidc.example.com/issuer', + ), + ), + testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: providerId2, + displayName: 'Page Test 2', + enabled: true, + clientId: 'TEST_CLIENT_ID', + 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..c453f98b 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,86 @@ 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'; + final url = Uri.parse( + 'http://$emulatorHost/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-key', + ); + 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()), + ); + }); + }); } From f141845a12b1b04883ba6b819852f28ac95f8379 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 23 Mar 2026 15:56:12 +0100 Subject: [PATCH 03/11] test: add unit tests for Firestore terminate method --- .../test/unit/firestore_test.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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); + }); + }); }); } From c1fe663441410fcb0f2ede136027f634e2fa691b Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 23 Mar 2026 15:56:20 +0100 Subject: [PATCH 04/11] test: add unit tests for ConditionMessage handling in messaging --- .../integration/messaging/messaging_test.dart | 45 +++++++++++++++++++ .../test/unit/messaging/messaging_test.dart | 24 ++++++++++ 2 files changed, 69 insertions(+) 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/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()), From b9d226c09b614132b4b771c6dd3d1f161bd2a2f8 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 23 Mar 2026 15:56:25 +0100 Subject: [PATCH 05/11] test: add unit tests for experimental URI handling in task queue --- .../integration/functions/functions_test.dart | 13 +++ .../test/unit/functions/functions_test.dart | 100 ++++++++++++++++++ 2 files changed, 113 insertions(+) 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/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(); + } + }); }); // =========================================================================== From 8ffe0341b7e3e839419ac7509e129fe5aea85694 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 24 Mar 2026 03:26:23 +0100 Subject: [PATCH 06/11] fix(auth): disable checkHeaderType for JWT verification Firebase session cookies omit the `typ` header claim. dart_jsonwebtoken 3.x added checkHeaderType=true as the default which rejects tokens without `typ: "JWT"`, causing verifySessionCookie to always fail with an invalid signature error. Disable the check since signature, issuer, audience, and expiry are all validated independently. --- packages/dart_firebase_admin/lib/src/utils/jwt.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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( From 07f5c659a6f31ed6e68b23344a395b24c52fc801 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 24 Mar 2026 03:27:42 +0100 Subject: [PATCH 07/11] fix(auth): pass toJson() to generateUpdateMask for OIDC/SAML updates generateUpdateMask expects a Map but was receiving a raw googleapis object, causing it to return an empty list. An empty update mask sent to the PATCH endpoint results in no fields being updated, so updateProviderConfig had no effect. Calling toJson() first produces the Map that generateUpdateMask can traverse. --- .../lib/src/auth/auth_request_handler.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From b68031e8c12eaefe79258e83f2da6ec4aadb8935 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 24 Mar 2026 03:28:02 +0100 Subject: [PATCH 08/11] fix(storage): unset STORAGE_EMULATOR_HOST when running in production mode The google_cloud_storage package reads STORAGE_EMULATOR_HOST via native FFI (getenv), bypassing Dart zone overrides. If an emulator-mode Storage instance runs first it sets the native env var, which then leaks into any subsequent production Storage instance in the same process, causing getDownloadURL to hit the emulator and return 404. Add unsetNativeEnvironmentVariable and call it in Storage._ when not in emulator mode to clear any previously set value. --- .../lib/src/storage/storage.dart | 4 ++++ .../lib/src/utils/native_environment.dart | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) 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/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); + }); + } +} From 4aeb12fa6654a11aed4d68edc56d0ac5b26d6c63 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 24 Mar 2026 03:28:11 +0100 Subject: [PATCH 09/11] fix(app-check): include ttl claim in custom token when ttlMillis is set The Firebase App Check exchangeCustomToken API reads the TTL for the resulting token from the signed custom token's claims, not from the exchange request body. The ttlMillis option was accepted but silently dropped because token_generator.dart never wrote a ttl claim into the JWT body. --- .../dart_firebase_admin/lib/src/app_check/token_generator.dart | 1 + 1 file changed, 1 insertion(+) 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)}'; From ffc04b08493c5d03841854016a001a6a14af0291 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 24 Mar 2026 03:29:30 +0100 Subject: [PATCH 10/11] fix(test): add clientSecret to OIDC provider config test fixtures OIDCAuthProviderConfig._validate requires clientSecret to be a non-empty string when creating a provider config. The integration test fixtures were missing this field, causing all OIDC createProviderConfig calls to throw. --- .../test/integration/auth/auth_prod_test.dart | 5 +++++ 1 file changed, 5 insertions(+) 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 5c0d0e18..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 @@ -542,6 +542,7 @@ void main() { displayName: 'Get Test Provider', enabled: true, clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', issuer: 'https://oidc.example.com/issuer', ), ); @@ -611,6 +612,7 @@ void main() { displayName: 'Original Name', enabled: true, clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', issuer: 'https://oidc.example.com/issuer', ), ); @@ -709,6 +711,7 @@ void main() { displayName: 'List Test Provider', enabled: true, clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', issuer: 'https://oidc.example.com/issuer', ), ); @@ -811,6 +814,7 @@ void main() { displayName: 'Page Test 1', enabled: true, clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', issuer: 'https://oidc.example.com/issuer', ), ), @@ -820,6 +824,7 @@ void main() { displayName: 'Page Test 2', enabled: true, clientId: 'TEST_CLIENT_ID', + clientSecret: 'TEST_CLIENT_SECRET', issuer: 'https://oidc.example.com/issuer', ), ), From 698e873c7b44a858286c3af14f059028230dbfba Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 24 Mar 2026 11:11:18 +0100 Subject: [PATCH 11/11] fix(test): use conventional placeholder for API key in auth emulator tests --- .../test/integration/auth/auth_test.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 c453f98b..efe70021 100644 --- a/packages/dart_firebase_admin/test/integration/auth/auth_test.dart +++ b/packages/dart_firebase_admin/test/integration/auth/auth_test.dart @@ -611,8 +611,12 @@ void main() { 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=fake-key', + 'http://$emulatorHost/identitytoolkit.googleapis.com/v1/accounts:signUp?key=$emulatorApiKey', ); final response = await post( url,