diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 419d5e717..1527b3bf4 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -69,8 +69,9 @@ class GoTrueClient { DateTime? _jwksCachedAt; final _onAuthStateChangeController = BehaviorSubject(); - final _onAuthStateChangeControllerSync = - BehaviorSubject(sync: true); + final _onAuthStateChangeControllerSync = BehaviorSubject( + sync: true, + ); /// Local storage to store pkce code verifiers. final GotrueAsyncStorage? _asyncStorage; @@ -112,10 +113,7 @@ class GoTrueClient { GotrueAsyncStorage? asyncStorage, AuthFlowType flowType = AuthFlowType.pkce, }) : _url = url ?? Constants.defaultGotrueUrl, - _headers = { - ...Constants.defaultHeaders, - ...?headers, - }, + _headers = {...Constants.defaultHeaders, ...?headers}, _httpClient = httpClient, _asyncStorage = asyncStorage, _flowType = flowType { @@ -123,17 +121,15 @@ class GoTrueClient { final gotrueUrl = url ?? Constants.defaultGotrueUrl; _log.config( - 'Initialize GoTrueClient v$version with url: $_url, autoRefreshToken: $_autoRefreshToken, flowType: $_flowType, tickDuration: ${Constants.autoRefreshTickDuration}, tickThreshold: ${Constants.autoRefreshTickThreshold}'); + 'Initialize GoTrueClient v$version with url: $_url, autoRefreshToken: $_autoRefreshToken, flowType: $_flowType, tickDuration: ${Constants.autoRefreshTickDuration}, tickThreshold: ${Constants.autoRefreshTickThreshold}', + ); _log.finest('Initialize with headers: $_headers'); admin = GoTrueAdminApi( gotrueUrl, headers: _headers, httpClient: httpClient, ); - mfa = GoTrueMFAApi( - client: this, - fetch: _fetch, - ); + mfa = GoTrueMFAApi(client: this, fetch: _fetch); if (_autoRefreshToken) { startAutoRefresh(); } @@ -207,8 +203,10 @@ class GoTrueClient { String? captchaToken, OtpChannel channel = OtpChannel.sms, }) async { - assert((email != null && phone == null) || (email == null && phone != null), - 'You must provide either an email or phone number'); + assert( + (email != null && phone == null) || (email == null && phone != null), + 'You must provide either an email or phone number', + ); late final Map response; @@ -216,12 +214,15 @@ class GoTrueClient { String? codeChallenge; if (_flowType == AuthFlowType.pkce) { - assert(_asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow.'); + assert( + _asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow.', + ); final codeVerifier = generatePKCEVerifier(); await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier); + key: '${Constants.defaultStorageKey}-code-verifier', + value: codeVerifier, + ); codeChallenge = generatePKCEChallenge(codeVerifier); } @@ -250,11 +251,15 @@ class GoTrueClient { 'channel': channel.name, }; final fetchOptions = GotrueRequestOptions(headers: _headers, body: body); - response = await _fetch.request('$_url/signup', RequestMethodType.post, - options: fetchOptions) as Map; + response = await _fetch.request( + '$_url/signup', + RequestMethodType.post, + options: fetchOptions, + ) as Map; } else { throw AuthException( - 'You must provide either an email or phone number and a password'); + 'You must provide either an email or phone number and a password', + ); } final authResponse = AuthResponse.fromJson(response); @@ -338,11 +343,14 @@ class GoTrueClient { /// Verifies the PKCE code verifyer and retrieves a session. Future exchangeCodeForSession(String authCode) async { - assert(_asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow.'); + assert( + _asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow.', + ); - final codeVerifierRawString = await _asyncStorage! - .getItem(key: '${Constants.defaultStorageKey}-code-verifier'); + final codeVerifierRawString = await _asyncStorage!.getItem( + key: '${Constants.defaultStorageKey}-code-verifier', + ); if (codeVerifierRawString == null) { throw AuthException('Code verifier could not be found in local storage.'); } @@ -355,21 +363,19 @@ class GoTrueClient { RequestMethodType.post, options: GotrueRequestOptions( headers: _headers, - body: { - 'auth_code': authCode, - 'code_verifier': codeVerifier, - }, - query: { - 'grant_type': 'pkce', - }, + body: {'auth_code': authCode, 'code_verifier': codeVerifier}, + query: {'grant_type': 'pkce'}, ), ); await _asyncStorage.removeItem( - key: '${Constants.defaultStorageKey}-code-verifier'); + key: '${Constants.defaultStorageKey}-code-verifier', + ); final authSessionUrlResponse = AuthSessionUrlResponse( - session: Session.fromJson(response)!, redirectType: redirectType?.name); + session: Session.fromJson(response)!, + redirectType: redirectType?.name, + ); final session = authSessionUrlResponse.session; _saveSession(session); @@ -420,9 +426,7 @@ class GoTrueClient { final authResponse = AuthResponse.fromJson(response); if (authResponse.session == null) { - throw AuthException( - 'An error occurred on token verification.', - ); + throw AuthException('An error occurred on token verification.'); } _saveSession(authResponse.session!); @@ -460,12 +464,15 @@ class GoTrueClient { if (email != null) { String? codeChallenge; if (_flowType == AuthFlowType.pkce) { - assert(_asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow.'); + assert( + _asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow.', + ); final codeVerifier = generatePKCEVerifier(); await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier); + key: '${Constants.defaultStorageKey}-code-verifier', + value: codeVerifier, + ); codeChallenge = generatePKCEChallenge(codeVerifier); } await _fetch.request( @@ -530,16 +537,21 @@ class GoTrueClient { if (!isRecoveryWithTokenHash) { assert( - ((email != null && phone == null) || - (email == null && phone != null)) || - (tokenHash != null), - '`email` or `phone` needs to be specified.'); - assert(token != null || tokenHash != null, - '`token` or `tokenHash` needs to be specified.'); + ((email != null && phone == null) || + (email == null && phone != null)) || + (tokenHash != null), + '`email` or `phone` needs to be specified.', + ); + assert( + token != null || tokenHash != null, + '`token` or `tokenHash` needs to be specified.', + ); } else { // For recovery with tokenHash, email/phone should not be provided - assert(email == null && phone == null, - 'For recovery type with tokenHash, only tokenHash and type should be provided.'); + assert( + email == null && phone == null, + 'For recovery type with tokenHash, only tokenHash and type should be provided.', + ); } final body = { @@ -553,21 +565,24 @@ class GoTrueClient { if (tokenHash != null) 'token_hash': tokenHash, }; final fetchOptions = GotrueRequestOptions(headers: _headers, body: body); - final response = await _fetch - .request('$_url/verify', RequestMethodType.post, options: fetchOptions); + final response = await _fetch.request( + '$_url/verify', + RequestMethodType.post, + options: fetchOptions, + ); final authResponse = AuthResponse.fromJson(response); if (authResponse.session == null) { - throw AuthException( - 'An error occurred on token verification.', - ); + throw AuthException('An error occurred on token verification.'); } _saveSession(authResponse.session!); - notifyAllSubscribers(type == OtpType.recovery - ? AuthChangeEvent.passwordRecovery - : AuthChangeEvent.signedIn); + notifyAllSubscribers( + type == OtpType.recovery + ? AuthChangeEvent.passwordRecovery + : AuthChangeEvent.signedIn, + ); return authResponse; } @@ -596,30 +611,36 @@ class GoTrueClient { String? codeChallenge; String? codeChallengeMethod; if (_flowType == AuthFlowType.pkce) { - assert(_asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow.'); + assert( + _asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow.', + ); final codeVerifier = generatePKCEVerifier(); await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier); + key: '${Constants.defaultStorageKey}-code-verifier', + value: codeVerifier, + ); codeChallenge = generatePKCEChallenge(codeVerifier); codeChallengeMethod = codeVerifier == codeChallenge ? 'plain' : 's256'; } - final res = await _fetch.request('$_url/sso', RequestMethodType.post, - options: GotrueRequestOptions( - body: { - if (providerId != null) 'provider_id': providerId, - if (domain != null) 'domain': domain, - if (redirectTo != null) 'redirect_to': redirectTo, - if (captchaToken != null) - 'gotrue_meta_security': {'captcha_token': captchaToken}, - 'skip_http_redirect': true, - 'code_challenge': codeChallenge, - 'code_challenge_method': codeChallengeMethod, - }, - headers: _headers, - )); + final res = await _fetch.request( + '$_url/sso', + RequestMethodType.post, + options: GotrueRequestOptions( + body: { + if (providerId != null) 'provider_id': providerId, + if (domain != null) 'domain': domain, + if (redirectTo != null) 'redirect_to': redirectTo, + if (captchaToken != null) + 'gotrue_meta_security': {'captcha_token': captchaToken}, + 'skip_http_redirect': true, + 'code_challenge': codeChallenge, + 'code_challenge_method': codeChallengeMethod, + }, + headers: _headers, + ), + ); return res['url'] as String; } @@ -650,8 +671,10 @@ class GoTrueClient { throw AuthSessionMissingException(); } - final options = - GotrueRequestOptions(headers: headers, jwt: session.accessToken); + final options = GotrueRequestOptions( + headers: headers, + jwt: session.accessToken, + ); await _fetch.request( '$_url/reauthenticate', @@ -672,15 +695,21 @@ class GoTrueClient { String? emailRedirectTo, String? captchaToken, }) async { - assert((email != null && phone == null) || (email == null && phone != null), - '`email` or `phone` needs to be specified.'); + assert( + (email != null && phone == null) || (email == null && phone != null), + '`email` or `phone` needs to be specified.', + ); if (email != null) { - assert([OtpType.signup, OtpType.emailChange].contains(type), - 'email must be provided for type ${type.name}'); + assert( + [OtpType.signup, OtpType.emailChange].contains(type), + 'email must be provided for type ${type.name}', + ); } if (phone != null) { - assert([OtpType.sms, OtpType.phoneChange].contains(type), - 'phone must be provided for type ${type.name}'); + assert( + [OtpType.sms, OtpType.phoneChange].contains(type), + 'phone must be provided for type ${type.name}', + ); } final body = { @@ -743,8 +772,11 @@ class GoTrueClient { jwt: accessToken, redirectTo: emailRedirectTo, ); - final response = await _fetch.request('$_url/user', RequestMethodType.put, - options: options); + final response = await _fetch.request( + '$_url/user', + RequestMethodType.put, + options: options, + ); final userResponse = UserResponse.fromJson(response); _currentSession = currentSession?.copyWith(user: userResponse.user); @@ -825,18 +857,15 @@ class GoTrueClient { final errorCode = url.queryParameters['error_code']; final error = url.queryParameters['error']; if (errorDescription != null) { - throw AuthException( - errorDescription, - statusCode: errorCode, - code: error, - ); + throw AuthException(errorDescription, statusCode: errorCode, code: error); } if (_flowType == AuthFlowType.pkce) { final authCode = originUrl.queryParameters['code']; if (authCode == null) { throw AuthPKCEGrantCodeExchangeError( - 'No code detected in query parameters.'); + 'No code detected in query parameters.', + ); } return await exchangeCodeForSession(authCode); } @@ -895,16 +924,15 @@ class GoTrueClient { /// [scope] determines which sessions should be logged out. /// /// If using [SignOutScope.others] scope, no [AuthChangeEvent.signedOut] event is fired! - Future signOut({ - SignOutScope scope = SignOutScope.local, - }) async { + Future signOut({SignOutScope scope = SignOutScope.local}) async { _log.info('Signing out user with scope: $scope'); final accessToken = currentSession?.accessToken; if (scope != SignOutScope.others) { _removeSession(); await _asyncStorage?.removeItem( - key: '${Constants.defaultStorageKey}-code-verifier'); + key: '${Constants.defaultStorageKey}-code-verifier', + ); notifyAllSubscribers(AuthChangeEvent.signedOut); } @@ -932,8 +960,10 @@ class GoTrueClient { }) async { String? codeChallenge; if (_flowType == AuthFlowType.pkce) { - assert(_asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow.'); + assert( + _asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow.', + ); final codeVerifier = generatePKCEVerifier(); await _asyncStorage!.setItem( key: '${Constants.defaultStorageKey}-code-verifier', @@ -1007,9 +1037,7 @@ class GoTrueClient { final authResponse = AuthResponse.fromJson(response); if (authResponse.session == null) { - throw AuthException( - 'An error occurred on token verification.', - ); + throw AuthException('An error occurred on token verification.'); } _saveSession(authResponse.session!); @@ -1033,11 +1061,14 @@ class GoTrueClient { queryParams: queryParams, skipBrowserRedirect: true, ); - final res = await _fetch.request(urlResponse.url, RequestMethodType.get, - options: GotrueRequestOptions( - headers: _headers, - jwt: _currentSession?.accessToken, - )); + final res = await _fetch.request( + urlResponse.url, + RequestMethodType.get, + options: GotrueRequestOptions( + headers: _headers, + jwt: _currentSession?.accessToken, + ), + ); return OAuthResponse(provider: provider, url: res['url']); } @@ -1141,12 +1172,11 @@ class GoTrueClient { return; } - final expiresInTicks = - (DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000) - .difference(now) - .inMilliseconds / - Constants.autoRefreshTickDuration.inMilliseconds) - .floor(); + final expiresInTicks = (DateTime.fromMillisecondsSinceEpoch( + expiresAt * 1000, + ).difference(now).inMilliseconds / + Constants.autoRefreshTickDuration.inMilliseconds) + .floor(); _log.finer('Access token expires in $expiresInTicks ticks'); @@ -1171,18 +1201,23 @@ class GoTrueClient { attempt++; _log.fine('Attempt $attempt to refresh token'); final options = GotrueRequestOptions( - headers: _headers, - body: {'refresh_token': refreshToken}, - query: {'grant_type': 'refresh_token'}); - final response = await _fetch - .request('$_url/token', RequestMethodType.post, options: options); + headers: _headers, + body: {'refresh_token': refreshToken}, + query: {'grant_type': 'refresh_token'}, + ); + final response = await _fetch.request( + '$_url/token', + RequestMethodType.post, + options: options, + ); final authResponse = AuthResponse.fromJson(response); return authResponse; }, retryIf: (e) { // Do not retry if the next retry comes after the next tick. - final nextBackOff = - Duration(milliseconds: (200 * pow(2, attempt - 1).floor())); + final nextBackOff = Duration( + milliseconds: (200 * pow(2, attempt - 1).floor()), + ); return e is AuthRetryableFetchException && (DateTime.now().millisecondsSinceEpoch + @@ -1219,8 +1254,10 @@ class GoTrueClient { urlParams.addAll(queryParams); } if (_flowType == AuthFlowType.pkce) { - assert(_asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow.'); + assert( + _asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow.', + ); final codeVerifier = generatePKCEVerifier(); await _asyncStorage!.setItem( key: '${Constants.defaultStorageKey}-code-verifier', @@ -1260,12 +1297,15 @@ class GoTrueClient { final broadcastKey = "sb-${Uri.parse(_url).host.split(".").first}-auth-token"; - assert(_broadcastChannel == null, - 'Broadcast channel should not be started more than once.'); + assert( + _broadcastChannel == null, + 'Broadcast channel should not be started more than once.', + ); try { _broadcastChannel = web.getBroadcastChannel(broadcastKey); - _broadcastChannelSubscription = - _broadcastChannel?.onMessage.listen((messageEvent) { + _broadcastChannelSubscription = _broadcastChannel?.onMessage.listen(( + messageEvent, + ) { final rawEvent = messageEvent['event']; _log.finest('Received broadcast message: $messageEvent'); _log.info('Received broadcast event: $rawEvent'); @@ -1280,8 +1320,9 @@ class GoTrueClient { 'USER_UPDATED' => AuthChangeEvent.userUpdated, 'MFA_CHALLENGE_VERIFIED' => AuthChangeEvent.mfaChallengeVerified, // This case should never happen though - _ => AuthChangeEvent.values - .firstWhereOrNull((event) => event.name == rawEvent), + _ => AuthChangeEvent.values.firstWhereOrNull( + (event) => event.name == rawEvent, + ), }; if (event != null) { @@ -1491,17 +1532,19 @@ class GoTrueClient { if (signingKey == null) { await getUser(token); return GetClaimsResponse( - claims: decoded.payload, - header: decoded.header, - signature: decoded.signature); + claims: decoded.payload, + header: decoded.header, + signature: decoded.signature, + ); } try { JWT.verify(token, signingKey.rsaPublicKey); return GetClaimsResponse( - claims: decoded.payload, - header: decoded.header, - signature: decoded.signature); + claims: decoded.payload, + header: decoded.header, + signature: decoded.signature, + ); } catch (e) { throw AuthInvalidJwtException('Invalid JWT signature: $e'); } diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart index bcba5e681..c9660e37c 100644 --- a/packages/gotrue/lib/src/types/types.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -4,10 +4,7 @@ typedef BroadcastChannel = ({ void Function() close, }); -enum AuthFlowType { - implicit, - pkce, -} +enum AuthFlowType { implicit, pkce } /// An OAuth provider identifier. /// @@ -147,8 +144,9 @@ enum OAuthClientRegistrationType { const OAuthClientRegistrationType(this.value); static OAuthClientRegistrationType fromString(String value) { - return OAuthClientRegistrationType.values - .firstWhere((e) => e.value == value); + return OAuthClientRegistrationType.values.firstWhere( + (e) => e.value == value, + ); } } @@ -218,16 +216,23 @@ class OAuthClient { clientType: OAuthClientType.fromString(json['client_type'] as String), tokenEndpointAuthMethod: json['token_endpoint_auth_method'] as String, registrationType: OAuthClientRegistrationType.fromString( - json['registration_type'] as String), + json['registration_type'] as String, + ), clientUri: json['client_uri'] as String?, redirectUris: (json['redirect_uris'] as List).cast(), grantTypes: (json['grant_types'] as List) - .map((e) => OAuthClientGrantType.values - .firstWhere((gt) => gt.value == e as String)) + .map( + (e) => OAuthClientGrantType.values.firstWhere( + (gt) => gt.value == e as String, + ), + ) .toList(), responseTypes: (json['response_types'] as List) - .map((e) => OAuthClientResponseType.values - .firstWhere((rt) => rt.value == e as String)) + .map( + (e) => OAuthClientResponseType.values.firstWhere( + (rt) => rt.value == e as String, + ), + ) .toList(), scope: json['scope'] as String?, createdAt: json['created_at'] as String, diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index 3c243a1e1..546c9b2ad 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -28,8 +28,9 @@ void main() { setUp(() async { final res = await http.post( - Uri.parse('http://localhost:3000/rpc/reset_and_init_auth_data'), - headers: {'x-forwarded-for': '127.0.0.1'}); + Uri.parse('http://localhost:3000/rpc/reset_and_init_auth_data'), + headers: {'x-forwarded-for': '127.0.0.1'}, + ); if (res.body.isNotEmpty) throw res.body; newEmail = getNewEmail(); @@ -39,10 +40,7 @@ void main() { client = GoTrueClient( url: gotrueUrl, - headers: { - 'Authorization': 'Bearer $anonToken', - 'apikey': anonToken, - }, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, asyncStorage: asyncStorage, flowType: AuthFlowType.implicit, ); @@ -59,10 +57,7 @@ void main() { clientWithAuthConfirmOff = GoTrueClient( url: gotrueUrl, httpClient: NoEmailConfirmationHttpClient(), - headers: { - 'Authorization': 'Bearer $anonToken', - 'apikey': anonToken, - }, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, asyncStorage: asyncStorage, flowType: AuthFlowType.implicit, ); @@ -82,9 +77,7 @@ void main() { }); test('anonymous sign-in', () async { - final response = await client.signInAnonymously( - data: {'Hello': 'World'}, - ); + final response = await client.signInAnonymously(data: {'Hello': 'World'}); expect(response.session?.accessToken, isA()); expect(response.user?.isAnonymous, isTrue); expect(response.user?.userMetadata, {'Hello': 'World'}); @@ -103,18 +96,20 @@ void main() { expect(data?.user.id, isA()); expect(data?.user.userMetadata!['Hello'], 'World'); }); - test('signUp() with weak password throws AuthWeakPasswordException', - () async { - try { - await client.signUp(email: newEmail, password: '123'); - fail('signUp with weak password should throw exception'); - } on AuthException catch (error) { - expect(error, isA()); - expect(error.code, ErrorCode.weakPassword.code); - } catch (error) { - fail('signUp threw ${error.runtimeType} instead of AuthException'); - } - }); + test( + 'signUp() with weak password throws AuthWeakPasswordException', + () async { + try { + await client.signUp(email: newEmail, password: '123'); + fail('signUp with weak password should throw exception'); + } on AuthException catch (error) { + expect(error, isA()); + expect(error.code, ErrorCode.weakPassword.code); + } catch (error) { + fail('signUp threw ${error.runtimeType} instead of AuthException'); + } + }, + ); test('Parsing invalid URL should throw', () async { const expiresIn = 12345; @@ -123,7 +118,8 @@ void main() { const providerToken = 'my_provider_token_with_fragment'; final urlWithoutAccessToken = Uri.parse( - 'http://my-callback-url.com/welcome#expires_in=$expiresIn&refresh_token=$refreshToken&token_type=$tokenType&provider_token=$providerToken'); + 'http://my-callback-url.com/welcome#expires_in=$expiresIn&refresh_token=$refreshToken&token_type=$tokenType&provider_token=$providerToken', + ); try { await client.getSessionFromUrl(urlWithoutAccessToken); fail('getSessionFromUrl did not throw exception'); @@ -135,7 +131,8 @@ void main() { 'Unverified email with spotify. A confirmation email has been sent to your spotify email'; final urlWithoutAccessToken = Uri.parse( - 'http://my-callback-url.com/#error=unauthorized_client&error_code=401&error_description=${Uri.encodeComponent(errorMessage)}'); + 'http://my-callback-url.com/#error=unauthorized_client&error_code=401&error_description=${Uri.encodeComponent(errorMessage)}', + ); try { await client.getSessionFromUrl(urlWithoutAccessToken); fail('getSessionFromUrl did not throw exception'); @@ -145,7 +142,8 @@ void main() { expect(error.code, 'unauthorized_client'); } catch (error) { fail( - 'getSessionFromUrl threw ${error.runtimeType} instead of AuthException'); + 'getSessionFromUrl threw ${error.runtimeType} instead of AuthException', + ); } }); @@ -156,9 +154,11 @@ void main() { stream, emitsInOrder([ predicate( - (event) => event.event == AuthChangeEvent.signedIn), + (event) => event.event == AuthChangeEvent.signedIn, + ), predicate( - (event) => event.event == AuthChangeEvent.signedOut), + (event) => event.event == AuthChangeEvent.signedOut, + ), ]), ); @@ -192,17 +192,18 @@ void main() { }); test( - 'signUp() with autoConfirm off with phone should fail because Twilio is not setup', - () async { - try { - await clientWithAuthConfirmOff.signUp( - phone: phone1, - password: password, - ); - } catch (error) { - expect(error, isA()); - } - }); + 'signUp() with autoConfirm off with phone should fail because Twilio is not setup', + () async { + try { + await clientWithAuthConfirmOff.signUp( + phone: phone1, + password: password, + ); + } catch (error) { + expect(error, isA()); + } + }, + ); test('signUp() with email should throw error if used twice', () async { final localEmail = email1; @@ -227,8 +228,10 @@ void main() { }); test('signInWithPassword() with email', () async { - final response = - await client.signInWithPassword(email: email1, password: password); + final response = await client.signInWithPassword( + email: email1, + password: password, + ); final data = response.session; expect(data?.accessToken, isA()); @@ -249,8 +252,10 @@ void main() { }); test('signInWithPassword() with phone', () async { - final response = - await client.signInWithPassword(phone: phone1, password: password); + final response = await client.signInWithPassword( + phone: phone1, + password: password, + ); final data = response.session; expect(data?.accessToken, isA()); @@ -269,9 +274,7 @@ void main() { final newClient = GoTrueClient( url: gotrueUrl, - headers: { - 'apikey': anonToken, - }, + headers: {'apikey': anonToken}, ); expect(newClient.currentSession?.refreshToken ?? '', isEmpty); @@ -281,136 +284,147 @@ void main() { }); test( - 'Set session with an empty refresh token throws AuthSessionMissingException', - () async { - await expectLater( - () => client.setSession(''), - throwsA(isA()), - ); - }); + 'Set session with an empty refresh token throws AuthSessionMissingException', + () async { + await expectLater( + () => client.setSession(''), + throwsA(isA()), + ); + }, + ); test( - 'Set session with both access token and refresh token skips network refresh', - () async { - await client.signInWithPassword(email: email1, password: password); + 'Set session with both access token and refresh token skips network refresh', + () async { + await client.signInWithPassword(email: email1, password: password); - final refreshToken = client.currentSession?.refreshToken ?? ''; - final accessToken = client.currentSession?.accessToken ?? ''; - expect(refreshToken, isNotEmpty); - expect(accessToken, isNotEmpty); + final refreshToken = client.currentSession?.refreshToken ?? ''; + final accessToken = client.currentSession?.accessToken ?? ''; + expect(refreshToken, isNotEmpty); + expect(accessToken, isNotEmpty); - final newClient = GoTrueClient( - url: gotrueUrl, - headers: { - 'apikey': anonToken, - }, - ); + final newClient = GoTrueClient( + url: gotrueUrl, + headers: {'apikey': anonToken}, + ); - expect(newClient.currentSession, isNull); + expect(newClient.currentSession, isNull); - expect( - newClient.onAuthStateChange, - emits(predicate((s) => s.event == AuthChangeEvent.signedIn)), - ); + expect( + newClient.onAuthStateChange, + emits( + predicate((s) => s.event == AuthChangeEvent.signedIn), + ), + ); - final response = await newClient.setSession( - refreshToken, - accessToken: accessToken, - ); + final response = await newClient.setSession( + refreshToken, + accessToken: accessToken, + ); - expect(response.session, isNotNull); - expect(response.session?.accessToken, equals(accessToken)); - expect(response.session?.refreshToken, equals(refreshToken)); - expect(response.user, isNotNull); - expect(newClient.currentSession?.accessToken, equals(accessToken)); - }); + expect(response.session, isNotNull); + expect(response.session?.accessToken, equals(accessToken)); + expect(response.session?.refreshToken, equals(refreshToken)); + expect(response.user, isNotNull); + expect(newClient.currentSession?.accessToken, equals(accessToken)); + }, + ); - test('Set session with expired access token falls back to refresh token', - () async { - await client.signInWithPassword(email: email1, password: password); + test( + 'Set session with expired access token falls back to refresh token', + () async { + await client.signInWithPassword(email: email1, password: password); - final refreshToken = client.currentSession?.refreshToken ?? ''; - expect(refreshToken, isNotEmpty); + final refreshToken = client.currentSession?.refreshToken ?? ''; + expect(refreshToken, isNotEmpty); - // A JWT that is syntactically valid but expired (exp in the past). - // Header: {"alg":"HS256","typ":"JWT"} - // Payload: {"sub":"user","exp":1} (epoch second 1 = Jan 1, 1970) - // Signature: 3 zero bytes as valid base64url ("AAAA") - const expiredAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' - '.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxfQ' - '.AAAA'; + // A JWT that is syntactically valid but expired (exp in the past). + // Header: {"alg":"HS256","typ":"JWT"} + // Payload: {"sub":"user","exp":1} (epoch second 1 = Jan 1, 1970) + // Signature: 3 zero bytes as valid base64url ("AAAA") + const expiredAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + '.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxfQ' + '.AAAA'; - final newClient = GoTrueClient( - url: gotrueUrl, - headers: { - 'apikey': anonToken, - }, - ); + final newClient = GoTrueClient( + url: gotrueUrl, + headers: {'apikey': anonToken}, + ); - // Should fall back to _callRefreshToken and succeed. - final response = await newClient.setSession( - refreshToken, - accessToken: expiredAccessToken, - ); + // Should fall back to _callRefreshToken and succeed. + final response = await newClient.setSession( + refreshToken, + accessToken: expiredAccessToken, + ); - expect(response.session, isNotNull); - expect(response.session?.accessToken, isNot(equals(expiredAccessToken))); - expect(newClient.currentSession?.accessToken, isNotEmpty); - }); + expect(response.session, isNotNull); + expect( + response.session?.accessToken, + isNot(equals(expiredAccessToken)), + ); + expect(newClient.currentSession?.accessToken, isNotEmpty); + }, + ); test( - 'Set session with empty access token throws AuthSessionMissingException', - () async { - await expectLater( - () => client.setSession('some-refresh-token', accessToken: ''), - throwsA(isA()), - ); - }); + 'Set session with empty access token throws AuthSessionMissingException', + () async { + await expectLater( + () => client.setSession('some-refresh-token', accessToken: ''), + throwsA(isA()), + ); + }, + ); test( - 'Set session with malformed access token throws AuthInvalidJwtException', - () async { - await expectLater( - () => client.setSession('some-refresh-token', - accessToken: 'not-a-valid-jwt'), - throwsA(isA()), - ); - }); + 'Set session with malformed access token throws AuthInvalidJwtException', + () async { + await expectLater( + () => client.setSession( + 'some-refresh-token', + accessToken: 'not-a-valid-jwt', + ), + throwsA(isA()), + ); + }, + ); - test('Refresh session with refreshToken when no current session exists', - () async { - await client.signInWithPassword(email: email1, password: password); + test( + 'Refresh session with refreshToken when no current session exists', + () async { + await client.signInWithPassword(email: email1, password: password); - final refreshToken = client.currentSession?.refreshToken ?? ''; - expect(refreshToken, isNotEmpty); + final refreshToken = client.currentSession?.refreshToken ?? ''; + expect(refreshToken, isNotEmpty); - final newClient = GoTrueClient( - url: gotrueUrl, - headers: { - 'apikey': anonToken, - }, - ); + final newClient = GoTrueClient( + url: gotrueUrl, + headers: {'apikey': anonToken}, + ); - expect(newClient.currentSession, isNull); + expect(newClient.currentSession, isNull); - // This should work even though there's no current session, - // because we're providing a refreshToken parameter - final response = await newClient.refreshSession(refreshToken); - expect(response.session, isNotNull); - expect(response.session?.accessToken, isNotEmpty); - expect(newClient.currentSession?.accessToken, isNotEmpty); - }); + // This should work even though there's no current session, + // because we're providing a refreshToken parameter + final response = await newClient.refreshSession(refreshToken); + expect(response.session, isNotNull); + expect(response.session?.accessToken, isNotEmpty); + expect(newClient.currentSession?.accessToken, isNotEmpty); + }, + ); test('Update user', () async { await client.signInWithPassword(email: email1, password: password); final response = await client.updateUser( - UserAttributes(data: { - 'hello': 'world', - 'japanese': '日本語', - 'korean': '한국어', - 'arabic': 'عربى', - }), + UserAttributes( + data: { + 'hello': 'world', + 'japanese': '日本語', + 'korean': '한국어', + 'arabic': 'عربى', + }, + ), ); final user = response.user; expect(user, client.currentUser); @@ -467,40 +481,49 @@ void main() { group('The auth client can signin with third-party oAuth providers', () { test('signIn() with Provider', () async { - final res = - await client.getOAuthSignInUrl(provider: OAuthProvider.google); + final res = await client.getOAuthSignInUrl( + provider: OAuthProvider.google, + ); expect(res.url, isA()); expect(res.provider, OAuthProvider.google); }); test('signIn() with Provider with redirectTo', () async { final res = await client.getOAuthSignInUrl( - provider: OAuthProvider.google, redirectTo: 'https://supabase.com'); - expect(res.url, - '$gotrueUrl/authorize?provider=google&redirect_to=https%3A%2F%2Fsupabase.com'); + provider: OAuthProvider.google, + redirectTo: 'https://supabase.com', + ); + expect( + res.url, + '$gotrueUrl/authorize?provider=google&redirect_to=https%3A%2F%2Fsupabase.com', + ); expect(res.provider, OAuthProvider.google); }); test('signIn() with Provider can append a redirectUrl', () async { final res = await client.getOAuthSignInUrl( - provider: OAuthProvider.google, - redirectTo: 'https://localhost:9000/welcome'); + provider: OAuthProvider.google, + redirectTo: 'https://localhost:9000/welcome', + ); expect(res.url, isA()); expect(res.provider, OAuthProvider.google); }); test('signIn() with Provider can append scopes', () async { final res = await client.getOAuthSignInUrl( - provider: OAuthProvider.google, scopes: 'repo'); + provider: OAuthProvider.google, + scopes: 'repo', + ); expect(res.url, isA()); expect(res.provider, OAuthProvider.google); }); test('signIn() with Provider can append options', () async { final res = await client.getOAuthSignInUrl( - provider: OAuthProvider.google, - redirectTo: 'https://localhost:9000/welcome', - scopes: 'repo'); + provider: OAuthProvider.google, + redirectTo: 'https://localhost:9000/welcome', + scopes: 'repo', + ); expect(res.url, isA()); expect(res.provider, OAuthProvider.google); }); @@ -518,10 +541,7 @@ void main() { final httpClient = RetryTestHttpClient(); final client = GoTrueClient( url: gotrueUrl, - headers: { - 'Authorization': 'Bearer $anonToken', - 'apikey': anonToken, - }, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, asyncStorage: TestAsyncStorage(), httpClient: httpClient, ); @@ -552,27 +572,29 @@ void main() { stream, emitsInOrder([ predicate( - (event) => event.event == AuthChangeEvent.signedIn), + (event) => event.event == AuthChangeEvent.signedIn, + ), predicate( - (event) => event.event == AuthChangeEvent.signedOut), + (event) => event.event == AuthChangeEvent.signedOut, + ), ]), ); - final expiredSession = - getSessionData(DateTime.now().subtract(Duration(hours: 1))); + final expiredSession = getSessionData( + DateTime.now().subtract(Duration(hours: 1)), + ); - await expectLater(client.recoverSession(expiredSession.sessionString), - throwsA(isA())); + await expectLater( + client.recoverSession(expiredSession.sessionString), + throwsA(isA()), + ); expect(stream, emitsError(isA())); expect(client.currentSession, isNull); }); test('Call getLinkIdentityUrl', () async { - await client.signInWithPassword( - email: email1, - password: password, - ); + await client.signInWithPassword(email: email1, password: password); final res = await client.getLinkIdentityUrl(OAuthProvider.google); expect(res.url, isA()); final uri = Uri.parse(res.url); @@ -584,10 +606,7 @@ void main() { late GoTrueClient client; setUpAll(() { - client = GoTrueClient( - url: gotrueUrl, - httpClient: CustomHttpClient(), - ); + client = GoTrueClient(url: gotrueUrl, httpClient: CustomHttpClient()); }); test('signIn()', () async { @@ -609,16 +628,14 @@ void main() { setUpAll(() { httpClient = RetryTestHttpClient(); - client = GoTrueClient( - url: gotrueUrl, - httpClient: httpClient, - ); + client = GoTrueClient(url: gotrueUrl, httpClient: httpClient); }); test('Session recovery succeeds after retries', () async { try { await client.recoverSession( - '{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"},"expiresAt":1680341705}'); + '{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"},"expiresAt":1680341705}', + ); } on ClientException { // the method should throw } @@ -641,18 +658,20 @@ void main() { ); }); - test('getOAuthSignInUrl with PKCE flow has the correct query parameters', - () async { - final response = await client.getOAuthSignInUrl( - provider: OAuthProvider.google, - ); - final url = Uri.parse(response.url); - final queryParameters = url.queryParameters; - expect(queryParameters['provider'], 'google'); - expect(queryParameters['flow_type'], 'pkce'); - expect(queryParameters['code_challenge_method'], 's256'); - expect(queryParameters['code_challenge'], isA()); - }); + test( + 'getOAuthSignInUrl with PKCE flow has the correct query parameters', + () async { + final response = await client.getOAuthSignInUrl( + provider: OAuthProvider.google, + ); + final url = Uri.parse(response.url); + final queryParameters = url.queryParameters; + expect(queryParameters['provider'], 'google'); + expect(queryParameters['flow_type'], 'pkce'); + expect(queryParameters['code_challenge_method'], 's256'); + expect(queryParameters['code_challenge'], isA()); + }, + ); test('Parsing an error URL should throw', () async { const errorMessage = @@ -660,7 +679,8 @@ void main() { // Supabase Auth returns a URL with `#` even when using pkce flow. final urlWithoutAccessToken = Uri.parse( - 'http://my-callback-url.com/#error=unauthorized_client&error_code=401&error_description=${Uri.encodeComponent(errorMessage)}'); + 'http://my-callback-url.com/#error=unauthorized_client&error_code=401&error_description=${Uri.encodeComponent(errorMessage)}', + ); try { await client.getSessionFromUrl(urlWithoutAccessToken); fail('getSessionFromUrl did not throw exception'); @@ -668,7 +688,8 @@ void main() { expect(error.message, errorMessage); } catch (error) { fail( - 'getSessionFromUrl threw ${error.runtimeType} instead of AuthException'); + 'getSessionFromUrl threw ${error.runtimeType} instead of AuthException', + ); } }); }); diff --git a/packages/gotrue/test/custom_oauth_provider_test.dart b/packages/gotrue/test/custom_oauth_provider_test.dart index 1e49deaa4..e2cbea148 100644 --- a/packages/gotrue/test/custom_oauth_provider_test.dart +++ b/packages/gotrue/test/custom_oauth_provider_test.dart @@ -21,24 +21,26 @@ void main() { expect(provider.snakeCase, 'custom:my-provider'); }); - test('getOAuthSignInUrl builds correct URL for a custom provider', - () async { - const gotrueUrl = 'http://localhost:9998'; - final client = GoTrueClient( - url: gotrueUrl, - headers: {}, - flowType: AuthFlowType.implicit, - ); + test( + 'getOAuthSignInUrl builds correct URL for a custom provider', + () async { + const gotrueUrl = 'http://localhost:9998'; + final client = GoTrueClient( + url: gotrueUrl, + headers: {}, + flowType: AuthFlowType.implicit, + ); - final provider = OAuthProvider('custom:my-provider'); - final res = await client.getOAuthSignInUrl(provider: provider); + final provider = OAuthProvider('custom:my-provider'); + final res = await client.getOAuthSignInUrl(provider: provider); - expect(res.provider, provider); - expect(res.url, startsWith('$gotrueUrl/authorize?')); + expect(res.provider, provider); + expect(res.url, startsWith('$gotrueUrl/authorize?')); - final uri = Uri.parse(res.url); - expect(uri.queryParameters['provider'], 'custom:my-provider'); - }); + final uri = Uri.parse(res.url); + expect(uri.queryParameters['provider'], 'custom:my-provider'); + }, + ); test('built-in providers still work as static constants', () { expect(OAuthProvider.google.name, 'google'); @@ -53,7 +55,9 @@ void main() { expect(OAuthProvider('google'), equals(OAuthProvider.google)); expect(OAuthProvider('custom:x'), equals(OAuthProvider('custom:x'))); expect( - OAuthProvider('custom:x'), isNot(equals(OAuthProvider('custom:y')))); + OAuthProvider('custom:x'), + isNot(equals(OAuthProvider('custom:y'))), + ); }); test('OAuthProvider.values contains all built-in providers', () { diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart index 64c8fb48e..75b750628 100644 --- a/packages/gotrue/test/src/set_session_test.dart +++ b/packages/gotrue/test/src/set_session_test.dart @@ -15,7 +15,7 @@ Map get _mockUserJson => { 'email': 'mock@example.com', 'app_metadata': { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, 'user_metadata': {}, 'created_at': '2024-01-01T00:00:00.000Z', @@ -43,16 +43,23 @@ class _SetSessionMockClient extends BaseClient { // Refresh-token fallback response with a freshly minted access token. final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; final iat = exp - 3600; - final freshAt = - _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + final freshAt = _makeRawJwt({ + 'exp': exp, + 'iat': iat, + 'sub': 'mock-user-id', + }); return StreamedResponse( - Stream.value(utf8.encode(jsonEncode({ - 'access_token': freshAt, - 'token_type': 'bearer', - 'expires_in': 3600, - 'refresh_token': 'new-refresh-token', - 'user': _mockUserJson, - }))), + Stream.value( + utf8.encode( + jsonEncode({ + 'access_token': freshAt, + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': 'new-refresh-token', + 'user': _mockUserJson, + }), + ), + ), 200, ); } @@ -67,8 +74,9 @@ class _SetSessionMockClient extends BaseClient { /// no auto-injected `iat`, no claim overrides. The signature is a stub; /// [decodeJwt] does not verify signatures. String _makeRawJwt(Map payload) { - final header = - base64Url.encode(utf8.encode(jsonEncode({'alg': 'HS256', 'typ': 'JWT'}))); + final header = base64Url.encode( + utf8.encode(jsonEncode({'alg': 'HS256', 'typ': 'JWT'})), + ); final body = base64Url.encode(utf8.encode(jsonEncode(payload))); const sig = 'AAAA'; return '$header.$body.$sig'; @@ -92,8 +100,11 @@ void main() { 'empty refresh token with a non-null access token throws before ' 'inspecting the access token', () async { final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; - final at = - _makeRawJwt({'exp': exp, 'iat': exp - 3600, 'sub': 'mock-user-id'}); + final at = _makeRawJwt({ + 'exp': exp, + 'iat': exp - 3600, + 'sub': 'mock-user-id', + }); await expectLater( () => client.setSession('', accessToken: at), @@ -108,11 +119,16 @@ void main() { 'as expired and falls back to the refresh-token path', () async { final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; // exp is 20 s in the future, inside the 30 s Constants.expiryMargin. - final at = _makeRawJwt( - {'exp': timeNow + 20, 'iat': timeNow - 3580, 'sub': 'mock-user-id'}); - - final response = - await client.setSession('some-refresh-token', accessToken: at); + final at = _makeRawJwt({ + 'exp': timeNow + 20, + 'iat': timeNow - 3580, + 'sub': 'mock-user-id', + }); + + final response = await client.setSession( + 'some-refresh-token', + accessToken: at, + ); expect(response.session, isNotNull); // The returned token must be the freshly refreshed one, not our near-expired JWT. @@ -126,8 +142,10 @@ void main() { // JWT without an exp claim: decodeJwt succeeds but exp == null. final at = _makeRawJwt({'role': 'authenticated', 'sub': 'mock-user-id'}); - final response = - await client.setSession('some-refresh-token', accessToken: at); + final response = await client.setSession( + 'some-refresh-token', + accessToken: at, + ); expect(response.session, isNotNull); expect(response.session?.accessToken, isNot(equals(at))); @@ -136,26 +154,32 @@ void main() { }); group('setSession — fast path session fields', () { - test('expiresIn equals exp minus iat when both claims are present', - () async { - final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; - final exp = iat + 3600; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); - - final response = - await client.setSession('some-refresh-token', accessToken: at); - - // expiresIn should be the total token lifetime (exp - iat = 3600). - expect(response.session?.expiresIn, equals(exp - iat)); - }); + test( + 'expiresIn equals exp minus iat when both claims are present', + () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + + final response = await client.setSession( + 'some-refresh-token', + accessToken: at, + ); + + // expiresIn should be the total token lifetime (exp - iat = 3600). + expect(response.session?.expiresIn, equals(exp - iat)); + }, + ); test('expiresIn is null when iat claim is absent', () async { final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; // JWT without iat. final at = _makeRawJwt({'exp': exp, 'sub': 'mock-user-id'}); - final response = - await client.setSession('some-refresh-token', accessToken: at); + final response = await client.setSession( + 'some-refresh-token', + accessToken: at, + ); expect(response.session?.expiresIn, isNull); }); @@ -165,26 +189,30 @@ void main() { final exp = iat + 3600; final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); - final response = - await client.setSession('some-refresh-token', accessToken: at); + final response = await client.setSession( + 'some-refresh-token', + accessToken: at, + ); // expiresAt is re-derived from the JWT's own exp, not from expiresIn. expect(response.session?.expiresAt, equals(exp)); }); - test('returned session preserves the supplied access and refresh tokens', - () async { - final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; - final exp = iat + 3600; - const refreshToken = 'my-refresh-token'; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); - - final response = await client.setSession(refreshToken, accessToken: at); - - expect(response.session?.accessToken, equals(at)); - expect(response.session?.refreshToken, equals(refreshToken)); - expect(response.session?.tokenType, equals('bearer')); - }); + test( + 'returned session preserves the supplied access and refresh tokens', + () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + const refreshToken = 'my-refresh-token'; + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + + final response = await client.setSession(refreshToken, accessToken: at); + + expect(response.session?.accessToken, equals(at)); + expect(response.session?.refreshToken, equals(refreshToken)); + expect(response.session?.tokenType, equals('bearer')); + }, + ); }); group('setSession — auth state events', () { @@ -204,13 +232,19 @@ void main() { test('expired-fallback path emits tokenRefreshed (not signedIn)', () async { final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Clearly expired token (exp well in the past). - final at = _makeRawJwt( - {'exp': timeNow - 100, 'iat': timeNow - 3700, 'sub': 'mock-user-id'}); + final at = _makeRawJwt({ + 'exp': timeNow - 100, + 'iat': timeNow - 3700, + 'sub': 'mock-user-id', + }); expect( client.onAuthStateChange, - emits(predicate( - (s) => s.event == AuthChangeEvent.tokenRefreshed)), + emits( + predicate( + (s) => s.event == AuthChangeEvent.tokenRefreshed, + ), + ), ); await client.setSession('some-refresh-token', accessToken: at); diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index a598d4399..6a531a4f2 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -1,5 +1,6 @@ import 'package:http/http.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:postgrest/postgrest.dart'; import 'package:postgrest/src/constants.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; @@ -12,6 +13,8 @@ class PostgrestClient { final Client? httpClient; final YAJsonIsolate _isolate; final bool _hasCustomIsolate; + final bool retryEnabled; + final Duration Function(int attempt)? _retryDelay; final _log = Logger('supabase.postgrest'); /// To create a [PostgrestClient], you need to provide an [url] endpoint. @@ -25,16 +28,23 @@ class PostgrestClient { /// [httpClient] is optional and can be used to provide a custom http client /// /// [isolate] is optional and can be used to provide a custom isolate, which is used for heavy json computation + /// + /// [retryEnabled] controls whether automatic retries are performed for GET and + /// HEAD requests that fail with HTTP 503, HTTP 520, or a network error. Defaults to `true`. + /// Use [PostgrestBuilder.retry] to override this per request. PostgrestClient( this.url, { Map? headers, String? schema, this.httpClient, YAJsonIsolate? isolate, + this.retryEnabled = true, + @visibleForTesting Duration Function(int attempt)? retryDelay, }) : _schema = schema, headers = {...defaultHeaders, if (headers != null) ...headers}, _isolate = isolate ?? (YAJsonIsolate()..initialize()), - _hasCustomIsolate = isolate != null { + _hasCustomIsolate = isolate != null, + _retryDelay = retryDelay { _log.config('Initialize PostgrestClient with url: $url, schema: $_schema'); _log.finest('Initialize with headers: $headers'); } @@ -65,6 +75,8 @@ class PostgrestClient { schema: _schema, httpClient: httpClient, isolate: _isolate, + retryEnabled: retryEnabled, + retryDelay: _retryDelay, ); } @@ -78,6 +90,8 @@ class PostgrestClient { schema: schema, httpClient: httpClient, isolate: _isolate, + retryEnabled: retryEnabled, + retryDelay: _retryDelay, ); } @@ -108,6 +122,8 @@ class PostgrestClient { schema: _schema, httpClient: httpClient, isolate: _isolate, + retryEnabled: retryEnabled, + retryDelay: _retryDelay, ).rpc(params, get); } diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 5b41c39bd..7ab385bc7 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:core'; +import 'dart:math' as math; import 'package:http/http.dart' as http; import 'package:http/http.dart'; @@ -45,8 +46,13 @@ class PostgrestBuilder implements Future { final Client? _httpClient; final YAJsonIsolate? _isolate; final CountOption? _count; + final bool _retryEnabled; + final Duration Function(int attempt) _retryDelay; final _log = Logger('supabase.postgrest'); + static Duration _defaultRetryDelay(int attempt) => + Duration(seconds: math.min(math.pow(2, attempt).toInt(), 30)); + PostgrestBuilder({ required Uri url, required Headers headers, @@ -58,6 +64,8 @@ class PostgrestBuilder implements Future { CountOption? count, bool maybeSingle = false, PostgrestConverter? converter, + bool retryEnabled = true, + @visibleForTesting Duration Function(int attempt)? retryDelay, }) : _maybeSingle = maybeSingle, _method = method, _converter = converter, @@ -67,7 +75,9 @@ class PostgrestBuilder implements Future { _httpClient = httpClient, _isolate = isolate, _count = count, - _body = body; + _body = body, + _retryEnabled = retryEnabled, + _retryDelay = retryDelay ?? _defaultRetryDelay; PostgrestBuilder _copyWith({ Uri? url, @@ -80,6 +90,8 @@ class PostgrestBuilder implements Future { CountOption? count, bool? maybeSingle, PostgrestConverter? converter, + bool? retryEnabled, + Duration Function(int attempt)? retryDelay, }) { return PostgrestBuilder( url: url ?? _url, @@ -92,9 +104,20 @@ class PostgrestBuilder implements Future { count: count ?? _count, maybeSingle: maybeSingle ?? _maybeSingle, converter: converter ?? _converter, + retryEnabled: retryEnabled ?? _retryEnabled, + retryDelay: retryDelay ?? _retryDelay, ); } + /// Overrides the retry behavior for this specific request. + /// + /// When [enabled] is `false`, retries are disabled for this request even if + /// [PostgrestClient] was configured with `retryEnabled: true`. + /// When [enabled] is `true`, retries are enabled for this request even if + /// [PostgrestClient] was configured with `retryEnabled: false`. + PostgrestBuilder retry({required bool enabled}) => + _copyWith(retryEnabled: enabled); + PostgrestBuilder setHeader(String key, String value) { return _copyWith( headers: {..._headers, key: value}, @@ -103,13 +126,17 @@ class PostgrestBuilder implements Future { Future _execute() async { final String? method = _method; + // Work with a local copy so repeated awaits and shared-map siblings are + // not affected by per-execution header mutations (Prefer, schema headers, + // X-Retry-Count, etc.). + final execHeaders = {..._headers}; if (_count != null) { - if (_headers['Prefer'] != null) { - final oldPreferHeader = _headers['Prefer']; - _headers['Prefer'] = '$oldPreferHeader,count=${_count.name}'; + if (execHeaders['Prefer'] != null) { + final oldPreferHeader = execHeaders['Prefer']; + execHeaders['Prefer'] = '$oldPreferHeader,count=${_count.name}'; } else { - _headers['Prefer'] = 'count=${_count.name}'; + execHeaders['Prefer'] = 'count=${_count.name}'; } } @@ -121,62 +148,94 @@ class PostgrestBuilder implements Future { } final uppercaseMethod = method.toUpperCase(); - late http.Response response; if (_schema == null) { // skip } else if ([METHOD_GET, METHOD_HEAD].contains(method)) { - _headers['Accept-Profile'] = _schema; + execHeaders['Accept-Profile'] = _schema; } else { - _headers['Content-Profile'] = _schema; + execHeaders['Content-Profile'] = _schema; } if (method != METHOD_GET && method != METHOD_HEAD) { - _headers['Content-Type'] = 'application/json'; + execHeaders['Content-Type'] = 'application/json'; } final bodyStr = jsonEncode(_body); _log.finest("Request: $uppercaseMethod $_url"); + final Future Function() send; if (uppercaseMethod == METHOD_GET) { - response = await (_httpClient?.get ?? http.get)( - _url, - headers: _headers, - ); + send = () => (_httpClient?.get ?? http.get)(_url, headers: execHeaders); } else if (uppercaseMethod == METHOD_POST) { - response = await (_httpClient?.post ?? http.post)( - _url, - headers: _headers, - body: bodyStr, - ); + send = () => (_httpClient?.post ?? http.post)( + _url, + headers: execHeaders, + body: bodyStr, + ); } else if (uppercaseMethod == METHOD_PUT) { - response = await (_httpClient?.put ?? http.put)( - _url, - headers: _headers, - body: bodyStr, - ); + send = () => (_httpClient?.put ?? http.put)( + _url, + headers: execHeaders, + body: bodyStr, + ); } else if (uppercaseMethod == METHOD_PATCH) { - response = await (_httpClient?.patch ?? http.patch)( - _url, - headers: _headers, - body: bodyStr, - ); + send = () => (_httpClient?.patch ?? http.patch)( + _url, + headers: execHeaders, + body: bodyStr, + ); } else if (uppercaseMethod == METHOD_DELETE) { - response = await (_httpClient?.delete ?? http.delete)( - _url, - headers: _headers, - ); + send = () => + (_httpClient?.delete ?? http.delete)(_url, headers: execHeaders); } else if (uppercaseMethod == METHOD_HEAD) { - response = await (_httpClient?.head ?? http.head)( - _url, - headers: _headers, - ); + send = + () => (_httpClient?.head ?? http.head)(_url, headers: execHeaders); + } else { + throw StateError('Unknown HTTP method: $uppercaseMethod'); } + final response = + await _executeWithRetry(send, uppercaseMethod, execHeaders); return _parseResponse(response, method); } catch (error) { rethrow; } } + Future _executeWithRetry( + Future Function() send, + String method, + Map execHeaders, + ) async { + const maxRetries = 3; + const retryableStatusCodes = {503, 520}; + + final isRetryableMethod = method == METHOD_GET || method == METHOD_HEAD; + + if (!_retryEnabled || !isRetryableMethod) { + return send(); + } + + for (var attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + execHeaders['X-Retry-Count'] = attempt.toString(); + } + + try { + final response = await send(); + if (!retryableStatusCodes.contains(response.statusCode) || + attempt == maxRetries) { + return response; + } + } on Exception { + if (attempt == maxRetries) rethrow; + } + + await Future.delayed(_retryDelay(attempt)); + } + + throw StateError('unreachable'); + } + /// Parse request response to json object if possible Future _parseResponse(http.Response response, String method) async { if (response.statusCode >= 200 && response.statusCode <= 299) { diff --git a/packages/postgrest/lib/src/postgrest_filter_builder.dart b/packages/postgrest/lib/src/postgrest_filter_builder.dart index b922903ba..8364dab59 100644 --- a/packages/postgrest/lib/src/postgrest_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_filter_builder.dart @@ -515,6 +515,11 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { return copyWithUrl(appendSearchParams(column, 'isdistinct.$value')); } + @override + PostgrestFilterBuilder retry({required bool enabled}) { + return PostgrestFilterBuilder(_copyWith(retryEnabled: enabled)); + } + @override PostgrestFilterBuilder setHeader(String key, String value) { return PostgrestFilterBuilder( diff --git a/packages/postgrest/lib/src/postgrest_query_builder.dart b/packages/postgrest/lib/src/postgrest_query_builder.dart index e19439572..b18ea9acf 100644 --- a/packages/postgrest/lib/src/postgrest_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_query_builder.dart @@ -19,6 +19,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { String? schema, Client? httpClient, YAJsonIsolate? isolate, + bool retryEnabled = true, + Duration Function(int attempt)? retryDelay, }) : super( PostgrestBuilder( url: url, @@ -27,6 +29,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { schema: schema, httpClient: httpClient, isolate: isolate, + retryEnabled: retryEnabled, + retryDelay: retryDelay, ), ); @@ -259,6 +263,20 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { )); } + @override + PostgrestQueryBuilder retry({required bool enabled}) { + return PostgrestQueryBuilder( + url: _url, + headers: _headers, + httpClient: _httpClient, + method: _method, + schema: _schema, + isolate: _isolate, + retryEnabled: enabled, + retryDelay: _retryDelay, + ); + } + @override PostgrestQueryBuilder setHeader(String key, String value) { return PostgrestQueryBuilder( @@ -268,6 +286,8 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { method: _method, schema: _schema, isolate: _isolate, + retryEnabled: _retryEnabled, + retryDelay: _retryDelay, ); } } diff --git a/packages/postgrest/lib/src/postgrest_rpc_builder.dart b/packages/postgrest/lib/src/postgrest_rpc_builder.dart index 9f5190e5c..85cad0122 100644 --- a/packages/postgrest/lib/src/postgrest_rpc_builder.dart +++ b/packages/postgrest/lib/src/postgrest_rpc_builder.dart @@ -7,6 +7,8 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder { String? schema, Client? httpClient, required YAJsonIsolate isolate, + bool retryEnabled = true, + Duration Function(int attempt)? retryDelay, }) : super( PostgrestBuilder( url: Uri.parse(url), @@ -14,6 +16,8 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder { schema: schema, httpClient: httpClient, isolate: isolate, + retryEnabled: retryEnabled, + retryDelay: retryDelay, ), ); diff --git a/packages/postgrest/lib/src/postgrest_transform_builder.dart b/packages/postgrest/lib/src/postgrest_transform_builder.dart index 36530a16f..f0622186c 100644 --- a/packages/postgrest/lib/src/postgrest_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_transform_builder.dart @@ -6,6 +6,11 @@ class PostgrestTransformBuilder extends RawPostgrestBuilder { PostgrestTransformBuilder copyWithUrl(Uri url) => PostgrestTransformBuilder(_copyWith(url: url)); + @override + PostgrestTransformBuilder retry({required bool enabled}) { + return PostgrestTransformBuilder(_copyWith(retryEnabled: enabled)); + } + @override PostgrestTransformBuilder setHeader(String key, String value) { return PostgrestTransformBuilder( diff --git a/packages/postgrest/lib/src/raw_postgrest_builder.dart b/packages/postgrest/lib/src/raw_postgrest_builder.dart index 5cf388049..314d98ede 100644 --- a/packages/postgrest/lib/src/raw_postgrest_builder.dart +++ b/packages/postgrest/lib/src/raw_postgrest_builder.dart @@ -14,6 +14,8 @@ class RawPostgrestBuilder extends PostgrestBuilder { isolate: builder._isolate, maybeSingle: builder._maybeSingle, converter: builder._converter, + retryEnabled: builder._retryEnabled, + retryDelay: builder._retryDelay, ); /// Very similar to [_copyWith], but allows changing the generics, therefore [_converter] is omitted @@ -38,6 +40,8 @@ class RawPostgrestBuilder extends PostgrestBuilder { isolate: isolate ?? _isolate, count: count ?? _count, maybeSingle: maybeSingle ?? _maybeSingle, + retryEnabled: _retryEnabled, + retryDelay: _retryDelay, )); } @@ -71,6 +75,8 @@ class RawPostgrestBuilder extends PostgrestBuilder { count: _count, maybeSingle: _maybeSingle, converter: converter, + retryEnabled: _retryEnabled, + retryDelay: _retryDelay, ); } } diff --git a/packages/postgrest/lib/src/response_postgrest_builder.dart b/packages/postgrest/lib/src/response_postgrest_builder.dart index 88efac962..578d8ba05 100644 --- a/packages/postgrest/lib/src/response_postgrest_builder.dart +++ b/packages/postgrest/lib/src/response_postgrest_builder.dart @@ -14,6 +14,8 @@ class ResponsePostgrestBuilder extends PostgrestBuilder { isolate: builder._isolate, maybeSingle: builder._maybeSingle, converter: builder._converter, + retryEnabled: builder._retryEnabled, + retryDelay: builder._retryDelay, ); @override @@ -49,6 +51,8 @@ class ResponsePostgrestBuilder extends PostgrestBuilder { count: _count, maybeSingle: _maybeSingle, converter: converter, + retryEnabled: _retryEnabled, + retryDelay: _retryDelay, ); } } diff --git a/packages/postgrest/test/retry_test.dart b/packages/postgrest/test/retry_test.dart new file mode 100644 index 000000000..f368b0799 --- /dev/null +++ b/packages/postgrest/test/retry_test.dart @@ -0,0 +1,217 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +typedef _ResponseFactory = Future Function(BaseRequest); + +_ResponseFactory _ok() => (req) async => StreamedResponse( + Stream.value(Uint8List.fromList('[]'.codeUnits)), + 200, + request: req, + headers: {'content-type': 'application/json'}, + ); + +_ResponseFactory _status(int code) => (req) async => StreamedResponse( + Stream.value( + Uint8List.fromList('{"message":"err","code":"$code"}'.codeUnits)), + code, + request: req, + headers: {'content-type': 'application/json'}, + ); + +_ResponseFactory _networkError() => + (_) async => throw const SocketException('Connection refused'); + +class _MockRetryClient extends BaseClient { + final List<_ResponseFactory> _responses; + final List requests = []; + + _MockRetryClient(this._responses); + + int get callCount => requests.length; + + @override + Future send(BaseRequest request) async { + final index = requests.length; + requests.add(request); + if (index >= _responses.length) { + throw StateError( + 'Unexpected call #${index + 1}, only ${_responses.length} configured'); + } + return _responses[index](request); + } +} + +PostgrestClient _buildClient( + _MockRetryClient mock, { + bool retryEnabled = true, +}) { + return PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + retryEnabled: retryEnabled, + retryDelay: (_) => Duration.zero, + ); +} + +void main() { + group('retry logic', () { + test('GET retries on 520 then succeeds, X-Retry-Count increments', + () async { + final mock = _MockRetryClient([_status(520), _status(520), _ok()]); + final client = _buildClient(mock); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 3); + // Initial attempt: no header + expect(mock.requests[0].headers['x-retry-count'], isNull); + // First retry: X-Retry-Count: 1 + expect(mock.requests[1].headers['x-retry-count'], '1'); + // Second retry: X-Retry-Count: 2 + expect(mock.requests[2].headers['x-retry-count'], '2'); + }); + + test('HEAD retries on 520 then succeeds', () async { + final mock = _MockRetryClient([ + _status(520), + (req) async => StreamedResponse( + Stream.empty(), + 200, + request: req, + headers: {'content-range': '*/4'}, + ), + ]); + final client = _buildClient(mock); + + final count = await client.from('users').count(); + + expect(count, 4); + expect(mock.callCount, 2); + expect(mock.requests[1].headers['x-retry-count'], '1'); + }); + + test('POST does not retry on 520', () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock); + + await expectLater( + client.from('users').insert({'name': 'foo'}), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('GET retries on 503 then succeeds', () async { + final mock = _MockRetryClient([_status(503), _ok()]); + final client = _buildClient(mock); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + expect(mock.requests[0].headers['x-retry-count'], isNull); + expect(mock.requests[1].headers['x-retry-count'], '1'); + }); + + test('GET does not retry on non-520 error (e.g., 400)', () async { + final mock = _MockRetryClient([_status(400)]); + final client = _buildClient(mock); + + await expectLater( + client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('GET retries on network error (SocketException)', () async { + final mock = _MockRetryClient([_networkError(), _ok()]); + final client = _buildClient(mock); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + expect(mock.requests[1].headers['x-retry-count'], '1'); + }); + + test('POST does not retry on network error', () async { + final mock = _MockRetryClient([_networkError()]); + final client = _buildClient(mock); + + await expectLater( + client.from('users').insert({'name': 'foo'}), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('exhausts all 3 retries (4 total calls) then throws on 520', () async { + final mock = _MockRetryClient( + [_status(520), _status(520), _status(520), _status(520)]); + final client = _buildClient(mock); + + await expectLater( + client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 4); + }); + + test('.retry(enabled: false) disables retry per-request', () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock); + + await expectLater( + client.from('users').select().retry(enabled: false), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('PostgrestClient(retryEnabled: false) disables retry globally', + () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock, retryEnabled: false); + + await expectLater( + client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('.retry(enabled: true) re-enables retry when client-level is false', + () async { + final mock = _MockRetryClient([_status(520), _ok()]); + final client = _buildClient(mock, retryEnabled: false); + + final result = await client.from('users').select().retry(enabled: true); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }); + + test('GET exhausts retries on repeated network errors then rethrows', + () async { + final mock = _MockRetryClient([ + _networkError(), + _networkError(), + _networkError(), + _networkError(), + ]); + final client = _buildClient(mock); + + await expectLater( + client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 4); + }); + }); +}