Skip to content

Commit 42ce6fa

Browse files
grdsdevclaude
andcommitted
test(gotrue): add edge case tests for setSession fast path
- New set_session_test.dart with 10 mock-based tests covering: - Validation order (empty RT checked before AT) - Expiry margin boundary (exp within 30 s treated as expired) - JWT without exp claim falls back to refresh - expiresIn = exp - iat when both claims present - expiresIn = null when iat absent - expiresAt matches JWT exp - Preserved access/refresh token values - Fast path fires signedIn, fallback fires tokenRefreshed - Concurrent calls deduplicated to one /user request - signedIn fires exactly once under concurrent load - Fix remaining try/catch in client_test.dart to use throwsA Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6b13148 commit 42ce6fa

2 files changed

Lines changed: 274 additions & 6 deletions

File tree

packages/gotrue/test/client_test.dart

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,10 @@ void main() {
283283
test(
284284
'Set session with an empty refresh token throws AuthSessionMissingException',
285285
() async {
286-
try {
287-
await client.setSession('');
288-
fail('setSession did not throw');
289-
} catch (error) {
290-
expect(error, isA<AuthSessionMissingException>());
291-
}
286+
await expectLater(
287+
() => client.setSession(''),
288+
throwsA(isA<AuthSessionMissingException>()),
289+
);
292290
});
293291

294292
test(
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
4+
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
5+
import 'package:gotrue/gotrue.dart';
6+
import 'package:http/http.dart';
7+
import 'package:test/test.dart';
8+
9+
import '../utils.dart';
10+
11+
// Minimal user payload accepted by User.fromJson.
12+
Map<String, dynamic> get _mockUserJson => {
13+
'id': 'mock-user-id',
14+
'aud': 'authenticated',
15+
'role': 'authenticated',
16+
'email': 'mock@example.com',
17+
'app_metadata': {
18+
'provider': 'email',
19+
'providers': ['email']
20+
},
21+
'user_metadata': {},
22+
'created_at': '2024-01-01T00:00:00.000Z',
23+
'updated_at': '2024-01-01T00:00:00.000Z',
24+
};
25+
26+
/// Mock HTTP client for setSession tests.
27+
///
28+
/// Handles `GET /user` (returns [_mockUserJson]) and
29+
/// `POST /token` (returns a fresh session via the refresh path).
30+
/// Use [userCallPause] to hold the /user response until the completer completes,
31+
/// which lets you race two concurrent calls.
32+
class _SetSessionMockClient extends BaseClient {
33+
int userCallCount = 0;
34+
Completer<void>? userCallPause;
35+
36+
@override
37+
Future<StreamedResponse> send(BaseRequest request) async {
38+
if (request.url.path.endsWith('/user')) {
39+
userCallCount++;
40+
if (userCallPause != null) {
41+
await userCallPause!.future;
42+
}
43+
return StreamedResponse(
44+
Stream.value(utf8.encode(jsonEncode(_mockUserJson))),
45+
200,
46+
);
47+
}
48+
49+
if (request.url.path.contains('/token')) {
50+
// Refresh-token fallback response with a freshly minted access token.
51+
final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600;
52+
final iat = exp - 3600;
53+
final freshAt = JWT({'exp': exp, 'iat': iat}, subject: 'mock-user-id')
54+
.sign(SecretKey('test-secret'));
55+
return StreamedResponse(
56+
Stream.value(utf8.encode(jsonEncode({
57+
'access_token': freshAt,
58+
'token_type': 'bearer',
59+
'expires_in': 3600,
60+
'refresh_token': 'new-refresh-token',
61+
'user': _mockUserJson,
62+
}))),
63+
200,
64+
);
65+
}
66+
67+
return StreamedResponse(Stream.empty(), 404);
68+
}
69+
}
70+
71+
/// Returns a signed JWT with the given [exp] and optional [iat] claims.
72+
/// If [iat] is null, the claim is omitted entirely.
73+
String _makeJwt({required int exp, int? iat}) {
74+
final payload = <String, dynamic>{'exp': exp};
75+
if (iat != null) payload['iat'] = iat;
76+
return JWT(payload, subject: 'mock-user-id').sign(SecretKey('test-secret'));
77+
}
78+
79+
void main() {
80+
late _SetSessionMockClient mockClient;
81+
late GoTrueClient client;
82+
83+
setUp(() {
84+
mockClient = _SetSessionMockClient();
85+
client = GoTrueClient(
86+
url: 'https://example.supabase.co',
87+
httpClient: mockClient,
88+
asyncStorage: TestAsyncStorage(),
89+
);
90+
});
91+
92+
group('setSession — validation edge cases', () {
93+
test(
94+
'empty refresh token with a non-null access token throws before '
95+
'inspecting the access token', () async {
96+
final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600;
97+
final at = _makeJwt(exp: exp, iat: exp - 3600);
98+
99+
await expectLater(
100+
() => client.setSession('', accessToken: at),
101+
throwsA(isA<AuthSessionMissingException>()),
102+
);
103+
// No network call should have been made.
104+
expect(mockClient.userCallCount, 0);
105+
});
106+
107+
test(
108+
'access token with exp within the 30-second expiry margin is treated '
109+
'as expired and falls back to the refresh-token path', () async {
110+
final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000;
111+
// exp is 20 s in the future, inside the 30 s Constants.expiryMargin.
112+
final at = _makeJwt(exp: timeNow + 20, iat: timeNow - 3580);
113+
114+
final response =
115+
await client.setSession('some-refresh-token', accessToken: at);
116+
117+
expect(response.session, isNotNull);
118+
// The returned token must be the freshly refreshed one, not our near-expired JWT.
119+
expect(response.session?.accessToken, isNot(equals(at)));
120+
expect(mockClient.userCallCount, 0); // /user was NOT called
121+
});
122+
123+
test(
124+
'access token with no exp claim is treated as expired and falls back '
125+
'to the refresh-token path', () async {
126+
// JWT without an exp claim: decodeJwt succeeds but exp == null.
127+
final at = JWT({'role': 'authenticated'}, subject: 'mock-user-id')
128+
.sign(SecretKey('test-secret'));
129+
130+
final response =
131+
await client.setSession('some-refresh-token', accessToken: at);
132+
133+
expect(response.session, isNotNull);
134+
expect(response.session?.accessToken, isNot(equals(at)));
135+
expect(mockClient.userCallCount, 0);
136+
});
137+
});
138+
139+
group('setSession — fast path session fields', () {
140+
test('expiresIn equals exp minus iat when both claims are present',
141+
() async {
142+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
143+
final exp = iat + 3600;
144+
final at = _makeJwt(exp: exp, iat: iat);
145+
146+
final response =
147+
await client.setSession('some-refresh-token', accessToken: at);
148+
149+
// expiresIn should be the total token lifetime (exp - iat = 3600).
150+
expect(response.session?.expiresIn, equals(exp - iat));
151+
});
152+
153+
test('expiresIn is null when iat claim is absent', () async {
154+
final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600;
155+
// JWT without iat.
156+
final at = _makeJwt(exp: exp);
157+
158+
final response =
159+
await client.setSession('some-refresh-token', accessToken: at);
160+
161+
expect(response.session?.expiresIn, isNull);
162+
});
163+
164+
test('expiresAt matches the exp claim in the JWT', () async {
165+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
166+
final exp = iat + 3600;
167+
final at = _makeJwt(exp: exp, iat: iat);
168+
169+
final response =
170+
await client.setSession('some-refresh-token', accessToken: at);
171+
172+
// expiresAt is re-derived from the JWT's own exp, not from expiresIn.
173+
expect(response.session?.expiresAt, equals(exp));
174+
});
175+
176+
test('returned session preserves the supplied access and refresh tokens',
177+
() async {
178+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
179+
final exp = iat + 3600;
180+
const refreshToken = 'my-refresh-token';
181+
final at = _makeJwt(exp: exp, iat: iat);
182+
183+
final response = await client.setSession(refreshToken, accessToken: at);
184+
185+
expect(response.session?.accessToken, equals(at));
186+
expect(response.session?.refreshToken, equals(refreshToken));
187+
expect(response.session?.tokenType, equals('bearer'));
188+
});
189+
});
190+
191+
group('setSession — auth state events', () {
192+
test('fast path emits signedIn (not tokenRefreshed)', () async {
193+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
194+
final exp = iat + 3600;
195+
final at = _makeJwt(exp: exp, iat: iat);
196+
197+
expect(
198+
client.onAuthStateChange,
199+
emits(predicate<AuthState>((s) => s.event == AuthChangeEvent.signedIn)),
200+
);
201+
202+
await client.setSession('some-refresh-token', accessToken: at);
203+
});
204+
205+
test('expired-fallback path emits tokenRefreshed (not signedIn)', () async {
206+
final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000;
207+
// Clearly expired token (exp well in the past).
208+
final at = _makeJwt(exp: timeNow - 100, iat: timeNow - 3700);
209+
210+
expect(
211+
client.onAuthStateChange,
212+
emits(predicate<AuthState>(
213+
(s) => s.event == AuthChangeEvent.tokenRefreshed)),
214+
);
215+
216+
await client.setSession('some-refresh-token', accessToken: at);
217+
});
218+
});
219+
220+
group('setSession — concurrent call deduplication', () {
221+
test(
222+
'two simultaneous fast-path calls issue only one GET /user request '
223+
'and both receive the same response', () async {
224+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
225+
final exp = iat + 3600;
226+
final at = _makeJwt(exp: exp, iat: iat);
227+
228+
// Pause the mock /user response so both calls are in-flight together.
229+
final pause = Completer<void>();
230+
mockClient.userCallPause = pause;
231+
232+
final future1 = client.setSession('some-refresh-token', accessToken: at);
233+
final future2 = client.setSession('some-refresh-token', accessToken: at);
234+
235+
// Unblock the single /user call that should be in-flight.
236+
pause.complete();
237+
238+
final results = await Future.wait([future1, future2]);
239+
240+
expect(mockClient.userCallCount, 1);
241+
expect(results[0].session?.accessToken, equals(at));
242+
expect(results[1].session?.accessToken, equals(at));
243+
});
244+
245+
test('deduplicated call emits signedIn exactly once', () async {
246+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
247+
final exp = iat + 3600;
248+
final at = _makeJwt(exp: exp, iat: iat);
249+
250+
final pause = Completer<void>();
251+
mockClient.userCallPause = pause;
252+
253+
final events = <AuthChangeEvent>[];
254+
final sub = client.onAuthStateChange.listen((s) => events.add(s.event));
255+
256+
final future1 = client.setSession('some-refresh-token', accessToken: at);
257+
final future2 = client.setSession('some-refresh-token', accessToken: at);
258+
259+
pause.complete();
260+
await Future.wait([future1, future2]);
261+
262+
// Flush microtasks so the stream listener has a chance to fire.
263+
await Future<void>.delayed(Duration.zero);
264+
265+
expect(events.where((e) => e == AuthChangeEvent.signedIn).length, 1);
266+
267+
await sub.cancel();
268+
});
269+
});
270+
}

0 commit comments

Comments
 (0)