Skip to content

Commit d50b550

Browse files
authored
test: added missing integration tests for Auth, Messaging, Functions and Firestore apis (#195)
* test: add unit tests for AppCheck token creation and verification * test: add unit tests for OIDC and SAML provider config operations * test: add unit tests for Firestore terminate method * test: add unit tests for ConditionMessage handling in messaging * test: add unit tests for experimental URI handling in task queue * 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. * 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. * 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. * 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. * 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. * fix(test): use conventional placeholder for API key in auth emulator tests
1 parent c818077 commit d50b550

File tree

14 files changed

+872
-100
lines changed

14 files changed

+872
-100
lines changed

packages/dart_firebase_admin/lib/src/app_check/token_generator.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class AppCheckTokenGenerator {
4343
'aud': firebaseAppCheckAudience,
4444
'exp': iat + (oneMinuteInSeconds * 5),
4545
'iat': iat,
46+
if (options?.ttlMillis case final ttl?) 'ttl': '${ttl.inSeconds}s',
4647
};
4748

4849
final token = '${_encodeSegment(header)}.${_encodeSegment(body)}';

packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ abstract class _AbstractAuthRequestHandler {
218218
options,
219219
ignoreMissingFields: true,
220220
);
221-
final updateMask = generateUpdateMask(request);
221+
final updateMask = generateUpdateMask(request?.toJson());
222222

223223
final response = await _httpClient.updateOAuthIdpConfig(
224224
request ?? auth2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig(),
@@ -252,7 +252,7 @@ abstract class _AbstractAuthRequestHandler {
252252
options,
253253
ignoreMissingFields: true,
254254
);
255-
final updateMask = generateUpdateMask(request);
255+
final updateMask = generateUpdateMask(request?.toJson());
256256
final response = await _httpClient.updateInboundSamlConfig(
257257
request ?? auth2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig(),
258258
providerId,

packages/dart_firebase_admin/lib/src/storage/storage.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class Storage implements FirebaseService {
2121
);
2222
}
2323
setNativeEnvironmentVariable('STORAGE_EMULATOR_HOST', emulatorHost);
24+
} else {
25+
// Ensure no emulator host leaks into production GCS client from a
26+
// previous emulator-mode Storage instance in the same process.
27+
unsetNativeEnvironmentVariable('STORAGE_EMULATOR_HOST');
2428
}
2529
_delegate = gcs.Storage();
2630
}

packages/dart_firebase_admin/lib/src/utils/jwt.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ class PublicKeySignatureVerifier implements SignatureVerifier {
192192
}
193193

194194
try {
195-
JWT.verify(token, key);
195+
// Firebase session cookies omit the `typ` header claim, which
196+
// dart_jsonwebtoken 3.x rejects by default. Disable the check here;
197+
// signature, issuer, audience, and expiry are validated separately.
198+
JWT.verify(token, key, checkHeaderType: false);
196199
} catch (e, stackTrace) {
197200
Error.throwWithStackTrace(
198201
JwtException(

packages/dart_firebase_admin/lib/src/utils/native_environment.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ final int Function(Pointer<Utf8>, Pointer<Utf8>, int) _setenv =
1212
int Function(Pointer<Utf8>, Pointer<Utf8>, int)
1313
>('setenv');
1414

15+
final int Function(Pointer<Utf8>) _unsetenv = DynamicLibrary.process()
16+
.lookupFunction<Int32 Function(Pointer<Utf8>), int Function(Pointer<Utf8>)>(
17+
'unsetenv',
18+
);
19+
1520
final int Function(Pointer<Utf16>, Pointer<Utf16>) _setEnvironmentVariableW =
1621
DynamicLibrary.open('kernel32.dll').lookupFunction<
1722
Int32 Function(Pointer<Utf16>, Pointer<Utf16>),
@@ -44,3 +49,19 @@ void setNativeEnvironmentVariable(String name, String value) {
4449
});
4550
}
4651
}
52+
53+
@internal
54+
void unsetNativeEnvironmentVariable(String name) {
55+
if (Platform.isWindows) {
56+
using((arena) {
57+
final namePtr = name.toNativeUtf16(allocator: arena);
58+
// Passing NULL as the second argument deletes the variable on Windows
59+
_setEnvironmentVariableW(namePtr, nullptr);
60+
});
61+
} else {
62+
using((arena) {
63+
final namePtr = name.toNativeUtf8(allocator: arena);
64+
_unsetenv(namePtr);
65+
});
66+
}
67+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import 'dart:async';
2+
3+
import 'package:dart_firebase_admin/app_check.dart';
4+
import 'package:dart_firebase_admin/src/app.dart';
5+
import 'package:test/test.dart';
6+
7+
import '../../fixtures/helpers.dart';
8+
9+
const _testAppId = '1:559949546715:android:13025aec6cc3243d0ab8fe';
10+
11+
void main() {
12+
group(
13+
'AppCheck (Production)',
14+
() {
15+
group('createToken()', () {
16+
test('returns a token with a non-empty JWT string', () {
17+
return runZoned(() async {
18+
final appName =
19+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
20+
final app = FirebaseApp.initializeApp(name: appName);
21+
final appCheck = AppCheck.internal(app);
22+
23+
try {
24+
final token = await appCheck.createToken(_testAppId);
25+
26+
expect(token.token, isNotEmpty);
27+
// A JWT has exactly two dots
28+
expect(token.token.split('.').length, equals(3));
29+
} finally {
30+
await app.close();
31+
}
32+
}, zoneValues: {envSymbol: prodEnv()});
33+
});
34+
35+
test('returns ttlMillis within the valid range (30min–7days)', () {
36+
return runZoned(() async {
37+
final appName =
38+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
39+
final app = FirebaseApp.initializeApp(name: appName);
40+
final appCheck = AppCheck.internal(app);
41+
42+
try {
43+
final token = await appCheck.createToken(_testAppId);
44+
45+
expect(
46+
token.ttlMillis,
47+
greaterThanOrEqualTo(
48+
const Duration(minutes: 30).inMilliseconds,
49+
),
50+
);
51+
expect(
52+
token.ttlMillis,
53+
lessThanOrEqualTo(const Duration(days: 7).inMilliseconds),
54+
);
55+
} finally {
56+
await app.close();
57+
}
58+
}, zoneValues: {envSymbol: prodEnv()});
59+
});
60+
61+
test('honours a custom ttlMillis option', () {
62+
return runZoned(() async {
63+
final appName =
64+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
65+
final app = FirebaseApp.initializeApp(name: appName);
66+
final appCheck = AppCheck.internal(app);
67+
68+
try {
69+
const customTtl = Duration(hours: 2);
70+
final token = await appCheck.createToken(
71+
_testAppId,
72+
AppCheckTokenOptions(ttlMillis: customTtl),
73+
);
74+
75+
expect(token.token, isNotEmpty);
76+
// Server rounds to the nearest second; allow ±1 second tolerance.
77+
expect(token.ttlMillis, closeTo(customTtl.inMilliseconds, 1000));
78+
} finally {
79+
await app.close();
80+
}
81+
}, zoneValues: {envSymbol: prodEnv()});
82+
});
83+
});
84+
85+
group('verifyToken()', () {
86+
test('returns decoded token with correct claims structure', () {
87+
return runZoned(() async {
88+
final appName =
89+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
90+
final app = FirebaseApp.initializeApp(name: appName);
91+
final appCheck = AppCheck.internal(app);
92+
93+
try {
94+
final token = await appCheck.createToken(_testAppId);
95+
final result = await appCheck.verifyToken(token.token);
96+
97+
final decoded = result.token;
98+
expect(result.appId, equals(_testAppId));
99+
expect(decoded.sub, equals(_testAppId));
100+
expect(
101+
decoded.iss,
102+
startsWith('https://firebaseappcheck.googleapis.com/'),
103+
);
104+
expect(decoded.aud, isNotEmpty);
105+
expect(decoded.exp, greaterThan(decoded.iat));
106+
} finally {
107+
await app.close();
108+
}
109+
}, zoneValues: {envSymbol: prodEnv()});
110+
});
111+
112+
test('sets alreadyConsumed to null when consume option is not set', () {
113+
return runZoned(() async {
114+
final appName =
115+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
116+
final app = FirebaseApp.initializeApp(name: appName);
117+
final appCheck = AppCheck.internal(app);
118+
119+
try {
120+
final token = await appCheck.createToken(_testAppId);
121+
final result = await appCheck.verifyToken(token.token);
122+
123+
expect(result.alreadyConsumed, isNull);
124+
} finally {
125+
await app.close();
126+
}
127+
}, zoneValues: {envSymbol: prodEnv()});
128+
});
129+
130+
test(
131+
'sets alreadyConsumed to false on first consume, true on second',
132+
() {
133+
return runZoned(() async {
134+
final appName =
135+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
136+
final app = FirebaseApp.initializeApp(name: appName);
137+
final appCheck = AppCheck.internal(app);
138+
139+
try {
140+
final token = await appCheck.createToken(_testAppId);
141+
142+
final first = await appCheck.verifyToken(
143+
token.token,
144+
VerifyAppCheckTokenOptions()..consume = true,
145+
);
146+
expect(first.alreadyConsumed, isFalse);
147+
148+
final second = await appCheck.verifyToken(
149+
token.token,
150+
VerifyAppCheckTokenOptions()..consume = true,
151+
);
152+
expect(second.alreadyConsumed, isTrue);
153+
} finally {
154+
await app.close();
155+
}
156+
}, zoneValues: {envSymbol: prodEnv()});
157+
},
158+
);
159+
160+
test('throws FirebaseAppCheckException for an invalid token', () {
161+
return runZoned(() async {
162+
final appName =
163+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
164+
final app = FirebaseApp.initializeApp(name: appName);
165+
final appCheck = AppCheck.internal(app);
166+
167+
try {
168+
await expectLater(
169+
() => appCheck.verifyToken('invalid.token.value'),
170+
throwsA(isA<FirebaseAppCheckException>()),
171+
);
172+
} finally {
173+
await app.close();
174+
}
175+
}, zoneValues: {envSymbol: prodEnv()});
176+
});
177+
178+
test('throws FirebaseAppCheckException for a tampered token', () {
179+
return runZoned(() async {
180+
final appName =
181+
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
182+
final app = FirebaseApp.initializeApp(name: appName);
183+
final appCheck = AppCheck.internal(app);
184+
185+
try {
186+
final token = await appCheck.createToken(_testAppId);
187+
// Corrupt the signature portion of the JWT.
188+
final parts = token.token.split('.');
189+
final tampered = '${parts[0]}.${parts[1]}.invalidsignature';
190+
191+
await expectLater(
192+
() => appCheck.verifyToken(tampered),
193+
throwsA(isA<FirebaseAppCheckException>()),
194+
);
195+
} finally {
196+
await app.close();
197+
}
198+
}, zoneValues: {envSymbol: prodEnv()});
199+
});
200+
});
201+
},
202+
skip: hasProdEnv
203+
? false
204+
: 'Requires GOOGLE_APPLICATION_CREDENTIALS and RUN_PROD_TESTS=true',
205+
);
206+
}

0 commit comments

Comments
 (0)