diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e4c83ad..b6ee10b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,29 +22,35 @@ jobs: - uses: actions/checkout@v3.1.0 with: fetch-depth: 2 - - uses: actions/setup-node@v4 - - uses: subosito/flutter-action@v2.7.1 - with: - channel: master - - name: Add pub cache bin to PATH - run: echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH - - name: Add pub cache to PATH - run: echo "PUB_CACHE="$HOME/.pub-cache"" >> $GITHUB_ENV - - - name: Install firebase CLI - run: npm install -g firebase-tools - - - name: Install dependencies - run: dart pub get && cd example && dart pub get && cd - - - - name: Check format - run: dart format --set-exit-if-changed . - - - name: Analyze - run: dart analyze - - - name: Run tests - run: ${{github.workspace}}/scripts/coverage.sh - - - name: Upload coverage to codecov - run: curl -s https://codecov.io/bash | bash + # - uses: actions/setup-node@v4 + # - uses: subosito/flutter-action@v2.7.1 + # with: + # channel: master + # - name: Add pub cache bin to PATH + # run: echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH + # - name: Add pub cache to PATH + # run: echo "PUB_CACHE="$HOME/.pub-cache"" >> $GITHUB_ENV + + # - name: Install firebase CLI + # run: npm install -g firebase-tools + + # - name: Install dependencies + # run: dart pub get && cd example && dart pub get && cd - + + # - name: Check format + # run: dart format --set-exit-if-changed . + + # - name: Analyze + # run: dart analyze + + - run: mkdir $HOME/.config/gcloud -p + - run: echo Foo > $HOME/.config/gcloud/application_default_credentials.json + env: + CREDS: ${{ secrets.CREDS }} + - run: cat $HOME/.config/gcloud/application_default_credentials.json + + # - name: Run tests + # run: ${{github.workspace}}/scripts/coverage.sh + + # - name: Upload coverage to codecov + # run: curl -s https://codecov.io/bash | bash diff --git a/.gitignore b/.gitignore index db5356d5..ef87ca11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ firebase-debug.log ui-debug.log firestore-debug.log +node_modules +packages/dart_firebase_admin/test/client/package-lock.json + build coverage diff --git a/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart b/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart index e65f78a4..b8ecfc0c 100644 --- a/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart +++ b/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart @@ -79,9 +79,7 @@ class FirebaseTokenVerifier { isEmulator: isEmulator, ); - final decodedIdToken = DecodedIdToken.fromMap(decoded.payload); - decodedIdToken.uid = decodedIdToken.sub; - return decodedIdToken; + return DecodedIdToken.fromMap(decoded.payload); } Future _decodeAndVerify( @@ -129,6 +127,13 @@ class FirebaseTokenVerifier { required bool isEmulator, String? audience, }) { + Never throws(String message) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + message, + ); + } + final header = fullDecodedToken.header ?? {}; final payload = fullDecodedToken.payload as Map; @@ -141,7 +146,6 @@ class FirebaseTokenVerifier { late final alg = header['alg']; late final sub = payload['sub']; - String? errorMessage; if (!isEmulator && !header.containsKey('kid')) { final isCustomToken = (payload['aud'] == _firebaseAudience); @@ -151,53 +155,55 @@ class FirebaseTokenVerifier { d is Map && d.containsKey('uid'); + String message; if (isCustomToken) { - errorMessage = '${tokenInfo.verifyApiName} expects $_shortNameArticle ' + message = '${tokenInfo.verifyApiName} expects $_shortNameArticle ' '${tokenInfo.shortName}, but was given a custom token.'; } else if (isLegacyCustomToken) { - errorMessage = '${tokenInfo.verifyApiName} expects $_shortNameArticle ' + message = '${tokenInfo.verifyApiName} expects $_shortNameArticle ' '${tokenInfo.shortName}, but was given a legacy custom token.'; } else { - errorMessage = '${tokenInfo.jwtName} has no "kid" claim.'; + message = '${tokenInfo.jwtName} has no "kid" claim.'; } - errorMessage += verifyJwtTokenDocsMessage; + throws(message); } else if (!isEmulator && alg != _algorithmRS256) { - errorMessage = '${tokenInfo.jwtName} has incorrect algorithm. ' + throws('${tokenInfo.jwtName} has incorrect algorithm. ' 'Expected "$_algorithmRS256" but got "$alg".' - '$verifyJwtTokenDocsMessage'; + '$verifyJwtTokenDocsMessage'); } else if (audience != null && !(payload['aud'] as String).contains(audience)) { - errorMessage = - '${tokenInfo.jwtName} has incorrect "aud" (audience) claim. ' - 'Expected "$audience" but got "${payload['aud']}".' - '$verifyJwtTokenDocsMessage'; + throws( + '${tokenInfo.jwtName} has incorrect "aud" (audience) claim. ' + 'Expected "$audience" but got "${payload['aud']}".' + '$verifyJwtTokenDocsMessage', + ); } else if (audience == null && payload['aud'] != projectId) { - errorMessage = - '${tokenInfo.jwtName} has incorrect "aud" (audience) claim. ' - 'Expected "$projectId" but got "${payload['aud']}".' - '$projectIdMatchMessage$verifyJwtTokenDocsMessage'; + throws( + '${tokenInfo.jwtName} has incorrect "aud" (audience) claim. ' + 'Expected "$projectId" but got "${payload['aud']}".' + '$projectIdMatchMessage$verifyJwtTokenDocsMessage', + ); } else if (payload['iss'] != '$issuer$projectId') { - errorMessage = '${tokenInfo.jwtName} has incorrect "iss" (issuer) claim. ' - 'Expected "$issuer$projectId" but got "${payload['iss']}".' - '$projectIdMatchMessage$verifyJwtTokenDocsMessage'; + throws( + '${tokenInfo.jwtName} has incorrect "iss" (issuer) claim. ' + 'Expected "$issuer$projectId" but got "${payload['iss']}".' + '$projectIdMatchMessage$verifyJwtTokenDocsMessage', + ); } else if (sub is! String) { - errorMessage = '${tokenInfo.jwtName} has no "sub" (subject) claim.' - '$verifyJwtTokenDocsMessage'; + throws( + '${tokenInfo.jwtName} has no "sub" (subject) claim.' + '$verifyJwtTokenDocsMessage', + ); } else if (sub.isEmpty) { - errorMessage = - '${tokenInfo.jwtName} has an empty string "sub" (subject) claim.' - '$verifyJwtTokenDocsMessage'; + throws( + '${tokenInfo.jwtName} has an empty string "sub" (subject) claim.' + '$verifyJwtTokenDocsMessage', + ); } else if (sub.length > 128) { - errorMessage = - '${tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.' - '$verifyJwtTokenDocsMessage'; - } - - if (errorMessage != null) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidArgument, - errorMessage, + throws( + '${tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.' + '$verifyJwtTokenDocsMessage', ); } } @@ -249,6 +255,14 @@ class TokenProvider { required this.tenant, }); + @internal + TokenProvider.fromMap(Map map) + : identities = Map.from(map['identities']! as Map), + signInProvider = map['sign_in_provider']! as String, + signInSecondFactor = map['sign_in_second_factor'] as String?, + secondFactorIdentifier = map['second_factor_identifier'] as String?, + tenant = map['tenant'] as String?; + /// Provider-specific identity details corresponding /// to the provider used to sign in the user. Map identities; @@ -313,19 +327,13 @@ class DecodedIdToken { email: map['email'] as String?, emailVerified: map['email_verified'] as bool?, exp: map['exp']! as int, - firebase: TokenProvider( - identities: Map.from(map['firebase']! as Map), - signInProvider: map['sign_in_provider']! as String, - signInSecondFactor: map['sign_in_second_factor'] as String?, - secondFactorIdentifier: map['second_factor_identifier'] as String?, - tenant: map['tenant'] as String?, - ), + firebase: TokenProvider.fromMap(map['firebase']! as Map), iat: map['iat']! as int, iss: map['iss']! as String, phoneNumber: map['phone_number'] as String?, picture: map['picture'] as String?, sub: map['sub']! as String, - uid: map['uid']! as String, + uid: map['sub']! as String, ); } diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart index 98ce88da..a4e28937 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart @@ -191,7 +191,6 @@ class Firestore { final reader = _DocumentReader( firestore: this, documents: documents, - transactionId: null, fieldMask: fieldMask, ); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart index 3efef57b..8847e338 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart @@ -97,7 +97,6 @@ class Transaction { return _withLazyStartedTransaction, DocumentSnapshot>( docRef, - fieldMask: null, resultFn: _getSingleFn, ); } @@ -168,8 +167,10 @@ class Transaction { /// A [Precondition] restricting this update. /// void update( - DocumentReference documentRef, Map data, - {Precondition? precondition}) { + DocumentReference documentRef, + Map data, { + Precondition? precondition, + }) { if (_writeBatch == null) { throw Exception(readOnlyWriteErrorMsg); } @@ -188,8 +189,10 @@ class Transaction { /// /// A delete for a non-existing document is treated as a success (unless /// [precondition] is specified, in which case it throws a [FirebaseFirestoreAdminException] with [FirestoreClientErrorCode.notFound]). - void delete(DocumentReference> documentRef, - {Precondition? precondition}) { + void delete( + DocumentReference> documentRef, { + Precondition? precondition, + }) { if (_writeBatch == null) { throw Exception(readOnlyWriteErrorMsg); } @@ -274,17 +277,22 @@ class Transaction { // response because we are not starting a new transaction return _transactionIdPromise! .then( - (transactionId) => resultFn(docRef, - transactionId: transactionId, fieldMask: fieldMask), + (transactionId) => resultFn( + docRef, + transactionId: transactionId, + fieldMask: fieldMask, + ), ) .then((r) => r.result); } else { if (_readOnlyReadTime != null) { // We do not start a transaction for read-only transactions // do not set _prevTransactionId - return resultFn(docRef, - readTime: _readOnlyReadTime, fieldMask: fieldMask) - .then((r) => r.result); + return resultFn( + docRef, + readTime: _readOnlyReadTime, + fieldMask: fieldMask, + ).then((r) => r.result); } else { // This is the first read of the transaction so we create the appropriate // options for lazily starting the transaction inside this first read op @@ -341,7 +349,9 @@ class Transaction { ); final result = await reader._get(); return _TransactionResult( - transaction: result.transaction, result: result.result.single); + transaction: result.transaction, + result: result.result.single, + ); } Future<_TransactionResult>>> _getBatchFn( @@ -362,7 +372,9 @@ class Transaction { final result = await reader._get(); return _TransactionResult( - transaction: result.transaction, result: result.result); + transaction: result.transaction, + result: result.result, + ); } Future _runTransaction( diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.dart b/packages/dart_firebase_admin/lib/src/utils/jwt.dart index ca064ac2..b7832201 100644 --- a/packages/dart_firebase_admin/lib/src/utils/jwt.dart +++ b/packages/dart_firebase_admin/lib/src/utils/jwt.dart @@ -9,12 +9,15 @@ class EmulatorSignatureVerifier implements SignatureVerifier { @override Future verify(String token) async { // Signature checks skipped for emulator; no need to fetch public keys. + try { verifyJwtSignature( token, SecretKey(''), ); } on JWTInvalidException catch (e) { + // Emulator tokens have "alg": "none" + if (e.message == 'unknown algorithm') return; if (e.message == 'invalid signature') return; rethrow; } @@ -122,11 +125,23 @@ class PublicKeySignatureVerifier implements SignatureVerifier { 'no-matching-kid-error', ); } - verifyJwtSignature( - token, - RSAPublicKey.cert(publicKey), - issueAt: Duration.zero, // Any past date should be valid - ); + + try { + verifyJwtSignature( + token, + RSAPublicKey.cert(publicKey), + issueAt: Duration.zero, // Any past date should be valid + ); + } catch (e, stackTrace) { + Error.throwWithStackTrace( + JwtError( + JwtErrorCode.invalidSignature, + 'Error while verifying signature of Firebase ID token: $e', + ), + stackTrace, + ); + } + // At this point most JWTException's should have been caught in // verifyJwtSignature, but we could still get some from JWT.decode above } on JWTException catch (e) { @@ -169,14 +184,6 @@ void verifyJwtSignature( ), stackTrace, ); - } catch (e, stackTrace) { - Error.throwWithStackTrace( - JwtError( - JwtErrorCode.invalidSignature, - 'Error while verifying signature of Firebase ID token: $e', - ), - stackTrace, - ); } } diff --git a/packages/dart_firebase_admin/pubspec.yaml b/packages/dart_firebase_admin/pubspec.yaml index beea4495..e94ae199 100644 --- a/packages/dart_firebase_admin/pubspec.yaml +++ b/packages/dart_firebase_admin/pubspec.yaml @@ -24,6 +24,7 @@ dev_dependencies: file: ^7.0.0 freezed: ^2.4.2 mocktail: ^1.0.1 + path: ^1.9.1 test: ^1.24.4 uuid: ^4.0.0 diff --git a/packages/dart_firebase_admin/test/auth/auth_test.dart b/packages/dart_firebase_admin/test/auth/auth_test.dart new file mode 100644 index 00000000..e69a2498 --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/auth_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dart_firebase_admin/auth.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../google_cloud_firestore/util/helpers.dart'; + +Future run( + String executable, + List arguments, { + String? workDir, +}) async { + final process = await Process.run( + executable, + arguments, + stdoutEncoding: utf8, + workingDirectory: workDir, + ); + + if (process.exitCode != 0) { + throw Exception(process.stderr); + } + + return process; +} + +Future npmInstall({ + String? workDir, +}) async => + run('npm', ['install'], workDir: workDir); + +/// Run test/client/get_id_token.js +Future getIdToken() async { + final path = p.join( + Directory.current.path, + 'test', + 'client', + ); + + await npmInstall(workDir: path); + + final process = await run( + 'node', + ['get_id_token.js'], + workDir: path, + ); + + return (process.stdout as String).trim(); +} + +void main() { + group('FirebaseAuth', () { + group('verifyIdToken', () { + test('in prod', () async { + final app = createApp(useEmulator: false); + final auth = Auth(app); + + final token = await getIdToken(); + final decodedToken = await auth.verifyIdToken(token); + + expect(decodedToken.aud, 'dart-firebase-admin'); + expect(decodedToken.uid, 'TmpgnnHo3JRjzQZjgBaYzQDyyZi2'); + expect(decodedToken.sub, 'TmpgnnHo3JRjzQZjgBaYzQDyyZi2'); + expect(decodedToken.email, 'foo@google.com'); + expect(decodedToken.emailVerified, false); + expect(decodedToken.phoneNumber, isNull); + expect(decodedToken.firebase.identities, { + 'email': ['foo@google.com'], + }); + expect(decodedToken.firebase.signInProvider, 'password'); + }); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/auth/token_verifier_test.dart b/packages/dart_firebase_admin/test/auth/token_verifier_test.dart new file mode 100644 index 00000000..b2363ab3 --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/token_verifier_test.dart @@ -0,0 +1,51 @@ +import 'package:dart_firebase_admin/src/auth.dart'; +import 'package:test/test.dart'; + +void main() { + group('DecodedIdToken', () { + test('.fromMap', () async { + final idToken = DecodedIdToken.fromMap( + { + 'aud': 'mock-aud', + 'auth_time': 1, + 'email': 'mock-email', + 'email_verified': true, + 'exp': 1, + 'firebase': { + 'identities': { + 'email': 'mock-email', + }, + 'sign_in_provider': 'mock-sign-in-provider', + 'sign_in_second_factor': 'mock-sign-in-second-factor', + 'second_factor_identifier': 'mock-second-factor-identifier', + 'tenant': 'mock-tenant', + }, + 'iat': 1, + 'iss': 'mock-iss', + 'phone_number': 'mock-phone-number', + 'picture': 'mock-picture', + 'sub': 'mock-sub', + }, + ); + expect(idToken.aud, 'mock-aud'); + expect(idToken.authTime, DateTime.fromMillisecondsSinceEpoch(1000)); + expect(idToken.email, 'mock-email'); + expect(idToken.emailVerified, true); + expect(idToken.exp, 1); + expect(idToken.firebase.identities, {'email': 'mock-email'}); + expect(idToken.firebase.signInProvider, 'mock-sign-in-provider'); + expect(idToken.firebase.signInSecondFactor, 'mock-sign-in-second-factor'); + expect( + idToken.firebase.secondFactorIdentifier, + 'mock-second-factor-identifier', + ); + expect(idToken.firebase.tenant, 'mock-tenant'); + expect(idToken.iat, 1); + expect(idToken.iss, 'mock-iss'); + expect(idToken.phoneNumber, 'mock-phone-number'); + expect(idToken.picture, 'mock-picture'); + expect(idToken.sub, 'mock-sub'); + expect(idToken.uid, 'mock-sub'); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/client/get_id_token.js b/packages/dart_firebase_admin/test/client/get_id_token.js new file mode 100644 index 00000000..459525bc --- /dev/null +++ b/packages/dart_firebase_admin/test/client/get_id_token.js @@ -0,0 +1,39 @@ +const { initializeApp } = require("firebase/app"); +const { + getAuth, + signInWithEmailAndPassword, + signOut, +} = require("firebase/auth"); + +// Your web app's Firebase configuration +const firebaseConfig = { + apiKey: "AIzaSyCyxPmn7XCrAgnW2AnDjr9VWWXJ1AX-ouQ", + authDomain: "dart-firebase-admin.firebaseapp.com", + databaseURL: + "https://dart-firebase-admin-default-rtdb.europe-west1.firebasedatabase.app", + projectId: "dart-firebase-admin", + storageBucket: "dart-firebase-admin.firebasestorage.app", + messagingSenderId: "559949546715", + appId: "1:559949546715:web:86bc35cdf9e2633c0ab8fe", +}; + +const firebase = initializeApp(firebaseConfig); +const auth = getAuth(firebase); + +async function main() { + try { + auth.setPersistence("NONE"); + + const user = await signInWithEmailAndPassword( + auth, + "foo@google.com", + "123456" + ); + + const token = await user.user.getIdToken(true); + console.log(token); + } finally { + await signOut(auth); + } +} +main(); diff --git a/packages/dart_firebase_admin/test/client/package.json b/packages/dart_firebase_admin/test/client/package.json new file mode 100644 index 00000000..8e449fea --- /dev/null +++ b/packages/dart_firebase_admin/test/client/package.json @@ -0,0 +1,14 @@ +{ + "name": "admin_playground_client", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "firebase": "^11.3.1" + } +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart index 301fd6f0..60af1454 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart @@ -120,7 +120,7 @@ void main() { }); test('Supports BigInt', () async { - final firestore = createFirestore(Settings(useBigInt: true)); + final firestore = createFirestore(settings: Settings(useBigInt: true)); await firestore.doc('collectionId/bigInt').set({ 'foo': BigInt.from(9223372036854775807), diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/transaction_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/transaction_test.dart index 6c7b809a..24b196ca 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/transaction_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/transaction_test.dart @@ -99,7 +99,7 @@ void main() { docRef3, ], fieldMasks: [ - FieldPath(['value']), + FieldPath(const ['value']), ], ); return Future.value(snapshot) @@ -109,7 +109,7 @@ void main() { [ {'value': 42}, {'value': 44}, - {'value': 'foo'} + {'value': 'foo'}, ], ); }, @@ -171,10 +171,13 @@ void main() { }, ); }, - throwsA(isA().having( + throwsA( + isA().having( (e) => e.errorCode.statusCode, 'statusCode', - StatusCode.notFound)), + StatusCode.notFound, + ), + ), ); }, ); @@ -191,8 +194,11 @@ void main() { await firestore.runTransaction( (transaction) async { - transaction.update(docRef, {'value': 44}, - precondition: precondition); + transaction.update( + docRef, + {'value': 44}, + precondition: precondition, + ); }, ); @@ -202,15 +208,21 @@ void main() { () async { await firestore.runTransaction( (transaction) async { - transaction.update(docRef, {'value': 46}, - precondition: precondition); + transaction.update( + docRef, + {'value': 46}, + precondition: precondition, + ); }, ); }, - throwsA(isA().having( + throwsA( + isA().having( (e) => e.errorCode.statusCode, 'statusCode', - StatusCode.failedPrecondition)), + StatusCode.failedPrecondition, + ), + ), ); }, ); @@ -299,10 +311,13 @@ void main() { }, ); }, - throwsA(isA().having( + throwsA( + isA().having( (e) => e.errorCode.statusCode, 'statusCode', - StatusCode.notFound)), + StatusCode.notFound, + ), + ), ); }, ); @@ -314,8 +329,11 @@ void main() { await initializeTest('simpleDocument'); final writeResult = await docRef.set({'value': 42}); - var precondition = Precondition.timestamp(Timestamp.fromDate( - DateTime.now().subtract(const Duration(days: 1)))); + var precondition = Precondition.timestamp( + Timestamp.fromDate( + DateTime.now().subtract(const Duration(days: 1)), + ), + ); expect( () async { @@ -325,10 +343,13 @@ void main() { }, ); }, - throwsA(isA().having( + throwsA( + isA().having( (e) => e.errorCode.statusCode, 'statusCode', - StatusCode.failedPrecondition)), + StatusCode.failedPrecondition, + ), + ), ); expect( diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart index 312346ea..fd7a0b4f 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart @@ -10,13 +10,15 @@ const projectId = 'dart-firebase-admin'; FirebaseAdminApp createApp({ FutureOr Function()? tearDown, Client? client, + bool useEmulator = true, }) { final credential = Credential.fromApplicationDefaultCredentials(); final app = FirebaseAdminApp.initializeApp( projectId, credential, client: client, - )..useEmulator(); + ); + if (useEmulator) app.useEmulator(); addTearDown(() async { if (tearDown != null) { @@ -28,8 +30,14 @@ FirebaseAdminApp createApp({ return app; } -Firestore createFirestore([Settings? settings]) { - final firestore = Firestore(createApp(), settings: settings); +Firestore createFirestore({ + Settings? settings, + bool useEmulator = true, +}) { + final firestore = Firestore( + createApp(useEmulator: useEmulator), + settings: settings, + ); addTearDown(() async { final collections = await firestore.listCollections();