From 4e0270a069ecf5aae42031c77200d268519ac99b Mon Sep 17 00:00:00 2001 From: lawinski <41792991+lawinski@users.noreply.github.com> Date: Mon, 12 Aug 2024 15:59:44 +0200 Subject: [PATCH] fix: Added missing error codes for AuthException (#995) * Add auth error codes enum * Add error code parameter for the auth exception * Handle error_code parameter * Add error_code for the getSessionFromUrl exception * Add basic asserts for error_codes * Replace AuthException with AuthSessionMissingException * Adjust logic to JS implementation. - Introduce api versioning - Change AuthErrorCode to ErrorCode * more alignment with the JS SDK * fix: use docker compose v2 * update auth server version and fix failing tests * fix failing test --------- Co-authored-by: dshukertjr --- .github/workflows/gotrue.yml | 4 +- .github/workflows/postgrest.yml | 4 +- .github/workflows/storage_client.yml | 4 +- infra/gotrue/docker-compose.yml | 2 +- packages/gotrue/lib/src/constants.dart | 11 +++ packages/gotrue/lib/src/fetch.dart | 57 ++++++++++-- packages/gotrue/lib/src/gotrue_client.dart | 17 ++-- .../gotrue/lib/src/types/api_version.dart | 41 +++++++++ .../gotrue/lib/src/types/auth_exception.dart | 36 ++++++-- packages/gotrue/lib/src/types/error_code.dart | 90 +++++++++++++++++++ packages/gotrue/test/api_version_test.dart | 42 +++++++++ packages/gotrue/test/client_test.dart | 34 ++++++- packages/gotrue/test/custom_http_client.dart | 22 +++++ packages/gotrue/test/fetch_test.dart | 83 +++++++++++++++++ 14 files changed, 412 insertions(+), 35 deletions(-) create mode 100644 packages/gotrue/lib/src/types/api_version.dart create mode 100644 packages/gotrue/lib/src/types/error_code.dart create mode 100644 packages/gotrue/test/api_version_test.dart create mode 100644 packages/gotrue/test/fetch_test.dart diff --git a/.github/workflows/gotrue.yml b/.github/workflows/gotrue.yml index c0a1e946..ce99bc64 100644 --- a/.github/workflows/gotrue.yml +++ b/.github/workflows/gotrue.yml @@ -53,8 +53,8 @@ jobs: - name: Build Docker image run: | cd ../../infra/gotrue - docker-compose down - docker-compose up -d + docker compose down + docker compose up -d - name: Sleep for 5 seconds uses: jakejarvis/wait-action@master diff --git a/.github/workflows/postgrest.yml b/.github/workflows/postgrest.yml index 5aecc4f4..55cac0b2 100644 --- a/.github/workflows/postgrest.yml +++ b/.github/workflows/postgrest.yml @@ -55,8 +55,8 @@ jobs: - name: Build Docker image run: | cd ../../infra/postgrest - docker-compose down - docker-compose up -d + docker compose down + docker compose up -d - name: Sleep for 5 seconds uses: jakejarvis/wait-action@master diff --git a/.github/workflows/storage_client.yml b/.github/workflows/storage_client.yml index eb07d1c7..043c97ec 100644 --- a/.github/workflows/storage_client.yml +++ b/.github/workflows/storage_client.yml @@ -52,8 +52,8 @@ jobs: - name: Build Docker image run: | cd ../../infra/storage_client - docker-compose down - docker-compose up -d + docker compose down + docker compose up -d - name: Sleep for 5 seconds uses: jakejarvis/wait-action@master diff --git a/infra/gotrue/docker-compose.yml b/infra/gotrue/docker-compose.yml index b5fdb573..fdf22620 100644 --- a/infra/gotrue/docker-compose.yml +++ b/infra/gotrue/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: gotrue: # Signup enabled, autoconfirm on - image: supabase/gotrue:v2.146.0 + image: supabase/auth:v2.151.0 ports: - '9998:9998' environment: diff --git a/packages/gotrue/lib/src/constants.dart b/packages/gotrue/lib/src/constants.dart index 49851917..e1bdc87d 100644 --- a/packages/gotrue/lib/src/constants.dart +++ b/packages/gotrue/lib/src/constants.dart @@ -1,3 +1,4 @@ +import 'package:gotrue/src/types/api_version.dart'; import 'package:gotrue/src/types/auth_response.dart'; import 'package:gotrue/src/version.dart'; @@ -20,6 +21,16 @@ class Constants { /// A token refresh will be attempted this many ticks before the current session expires. static const autoRefreshTickThreshold = 3; + + /// The name of the header that contains API version. + static const apiVersionHeaderName = 'x-supabase-api-version'; +} + +class ApiVersions { + static final v20240101 = ApiVersion( + name: '2024-01-01', + timestamp: DateTime.parse('2024-01-01T00:00:00.0Z'), + ); } enum AuthChangeEvent { diff --git a/packages/gotrue/lib/src/fetch.dart b/packages/gotrue/lib/src/fetch.dart index 7ca92692..c369f55d 100644 --- a/packages/gotrue/lib/src/fetch.dart +++ b/packages/gotrue/lib/src/fetch.dart @@ -1,7 +1,10 @@ import 'dart:convert'; import 'package:collection/collection.dart'; +import 'package:gotrue/src/constants.dart'; +import 'package:gotrue/src/types/api_version.dart'; import 'package:gotrue/src/types/auth_exception.dart'; +import 'package:gotrue/src/types/error_code.dart'; import 'package:gotrue/src/types/fetch_options.dart'; import 'package:http/http.dart'; @@ -28,6 +31,16 @@ class GotrueFetch { return error.toString(); } + String? _getErrorCode(dynamic error, String key) { + if (error is Map) { + final dynamic errorCode = error[key]; + if (errorCode is String) { + return errorCode; + } + } + return null; + } + AuthException _handleError(dynamic error) { if (error is! Response) { throw AuthRetryableFetchException(message: error.toString()); @@ -50,24 +63,44 @@ class GotrueFetch { message: error.toString(), originalError: error); } - // Check if weak password reasons only contain strings - if (data is Map && - data['weak_password'] is Map && - data['weak_password']['reasons'] is List && - (data['weak_password']['reasons'] as List).isNotEmpty && - (data['weak_password']['reasons'] as List) - .whereNot((element) => element is String) - .isEmpty) { + String? errorCode; + + final responseApiVersion = ApiVersion.fromResponse(error); + + if (responseApiVersion?.isSameOrAfter(ApiVersions.v20240101) ?? false) { + errorCode = _getErrorCode(data, 'code'); + } else { + errorCode = _getErrorCode(data, 'error_code'); + } + + if (errorCode == null) { + // Legacy support for weak password errors, when there were no error codes + // Check if weak password reasons only contain strings + if (data is Map && + data['weak_password'] is Map && + data['weak_password']['reasons'] is List && + (data['weak_password']['reasons'] as List).isNotEmpty && + (data['weak_password']['reasons'] as List) + .whereNot((element) => element is String) + .isEmpty) { + throw AuthWeakPasswordException( + message: _getErrorMessage(data), + statusCode: error.statusCode.toString(), + reasons: List.from(data['weak_password']['reasons']), + ); + } + } else if (errorCode == ErrorCode.weakPassword.code) { throw AuthWeakPasswordException( message: _getErrorMessage(data), statusCode: error.statusCode.toString(), - reasons: List.from(data['weak_password']['reasons']), + reasons: List.from(data['weak_password']?['reasons'] ?? []), ); } throw AuthApiException( _getErrorMessage(data), statusCode: error.statusCode.toString(), + code: errorCode, ); } @@ -77,6 +110,12 @@ class GotrueFetch { GotrueRequestOptions? options, }) async { final headers = options?.headers ?? {}; + + // Set the API version header if not already set + if (!headers.containsKey(Constants.apiVersionHeaderName)) { + headers[Constants.apiVersionHeaderName] = ApiVersions.v20240101.name; + } + if (options?.jwt != null) { headers['Authorization'] = 'Bearer ${options!.jwt}'; } diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 76e774dd..08c4e90e 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -607,7 +607,7 @@ class GoTrueClient { /// If the current session's refresh token is invalid, an error will be thrown. Future refreshSession([String? refreshToken]) async { if (currentSession?.accessToken == null) { - throw AuthException('Not logged in.'); + throw AuthSessionMissingException(); } final currentSessionRefreshToken = @@ -626,7 +626,7 @@ class GoTrueClient { Future reauthenticate() async { final session = currentSession; if (session == null) { - throw AuthException('Not logged in.'); + throw AuthSessionMissingException(); } final options = @@ -691,7 +691,7 @@ class GoTrueClient { /// Gets the current user details from current session or custom [jwt] Future getUser([String? jwt]) async { if (jwt == null && currentSession?.accessToken == null) { - throw AuthException('Cannot get user: no current session.'); + throw AuthSessionMissingException(); } final options = GotrueRequestOptions( headers: _headers, @@ -712,7 +712,7 @@ class GoTrueClient { }) async { final accessToken = currentSession?.accessToken; if (accessToken == null) { - throw AuthException('Not logged in.'); + throw AuthSessionMissingException(); } final body = attributes.toJson(); @@ -736,7 +736,7 @@ class GoTrueClient { /// Sets the session data from refresh_token and returns the current session. Future setSession(String refreshToken) async { if (refreshToken.isEmpty) { - throw AuthException('No current session.'); + throw AuthSessionMissingException('Refresh token cannot be empty'); } return await _callRefreshToken(refreshToken); } @@ -757,8 +757,13 @@ class GoTrueClient { final errorDescription = url.queryParameters['error_description']; final errorCode = url.queryParameters['error_code']; + final error = url.queryParameters['error']; if (errorDescription != null) { - throw AuthException(errorDescription, statusCode: errorCode); + throw AuthException( + errorDescription, + statusCode: errorCode, + code: error, + ); } if (_flowType == AuthFlowType.pkce) { diff --git a/packages/gotrue/lib/src/types/api_version.dart b/packages/gotrue/lib/src/types/api_version.dart new file mode 100644 index 00000000..b3b9840f --- /dev/null +++ b/packages/gotrue/lib/src/types/api_version.dart @@ -0,0 +1,41 @@ +import 'package:gotrue/src/constants.dart'; +import 'package:http/http.dart'; + +// Parses the API version which is 2YYY-MM-DD. */ +const String _apiVersionRegex = + r'^2[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])'; + +/// Represents the API versions supported by the package. + +/// Represents the API version specified by a [name] in the format YYYY-MM-DD. +class ApiVersion { + const ApiVersion({ + required this.name, + required this.timestamp, + }); + + final String name; + final DateTime timestamp; + + /// Parses the API version from the string date. + static ApiVersion? fromString(String version) { + if (!RegExp(_apiVersionRegex).hasMatch(version)) { + return null; + } + + final DateTime? timestamp = DateTime.tryParse('${version}T00:00:00.0Z'); + if (timestamp == null) return null; + return ApiVersion(name: version, timestamp: timestamp); + } + + /// Parses the API version from the response headers. + static ApiVersion? fromResponse(Response response) { + final version = response.headers[Constants.apiVersionHeaderName]; + return version != null ? fromString(version) : null; + } + + /// Returns true if this version is the same or after [other]. + bool isSameOrAfter(ApiVersion other) { + return timestamp.isAfter(other.timestamp) || name == other.name; + } +} diff --git a/packages/gotrue/lib/src/types/auth_exception.dart b/packages/gotrue/lib/src/types/auth_exception.dart index 0a02750e..683e6b2f 100644 --- a/packages/gotrue/lib/src/types/auth_exception.dart +++ b/packages/gotrue/lib/src/types/auth_exception.dart @@ -1,12 +1,26 @@ +import 'package:gotrue/src/types/error_code.dart'; + class AuthException implements Exception { + /// Human readable error message associated with the error. final String message; + + /// HTTP status code that caused the error. final String? statusCode; - const AuthException(this.message, {this.statusCode}); + /// Error code associated with the error. Most errors coming from + /// HTTP responses will have a code, though some errors that occur + /// before a response is received will not have one present. + /// In that case [statusCode] will also be null. + /// + /// Find the full list of error codes in our documentation. + /// https://supabase.com/docs/reference/dart/auth-error-codes + final String? code; + + const AuthException(this.message, {this.statusCode, this.code}); @override String toString() => - 'AuthException(message: $message, statusCode: $statusCode)'; + 'AuthException(message: $message, statusCode: $statusCode, errorCode: $code)'; @override bool operator ==(Object other) { @@ -14,11 +28,12 @@ class AuthException implements Exception { return other is AuthException && other.message == message && - other.statusCode == statusCode; + other.statusCode == statusCode && + other.code == code; } @override - int get hashCode => message.hashCode ^ statusCode.hashCode; + int get hashCode => message.hashCode ^ statusCode.hashCode ^ code.hashCode; } class AuthPKCEGrantCodeExchangeError extends AuthException { @@ -26,8 +41,11 @@ class AuthPKCEGrantCodeExchangeError extends AuthException { } class AuthSessionMissingException extends AuthException { - AuthSessionMissingException() - : super('Auth session missing!', statusCode: '400'); + AuthSessionMissingException([String? message]) + : super( + message ?? 'Auth session missing!', + statusCode: '400', + ); } class AuthRetryableFetchException extends AuthException { @@ -38,7 +56,7 @@ class AuthRetryableFetchException extends AuthException { } class AuthApiException extends AuthException { - AuthApiException(super.message, {super.statusCode}); + AuthApiException(super.message, {super.statusCode, super.code}); } class AuthUnknownException extends AuthException { @@ -53,7 +71,7 @@ class AuthWeakPasswordException extends AuthException { AuthWeakPasswordException({ required String message, - required String statusCode, + required super.statusCode, required this.reasons, - }) : super(message, statusCode: statusCode); + }) : super(message, code: ErrorCode.weakPassword.code); } diff --git a/packages/gotrue/lib/src/types/error_code.dart b/packages/gotrue/lib/src/types/error_code.dart new file mode 100644 index 00000000..5dd90309 --- /dev/null +++ b/packages/gotrue/lib/src/types/error_code.dart @@ -0,0 +1,90 @@ +// All error codes from the Supabase Auth API. The whole list can be found here: +// https://github.com/supabase/auth/blob/master/internal/api/errorcodes.go +import 'package:collection/collection.dart'; + +enum ErrorCode { + unexpectedFailure('unexpected_failure'), + validationFailed('validation_failed'), + badJson('bad_json'), + emailExists('email_exists'), + phoneExists('phone_exists'), + badJwt('bad_jwt'), + notAdmin('not_admin'), + noAuthorization('no_authorization'), + userNotFound('user_not_found'), + sessionNotFound('session_not_found'), + flowStateNotFound('flow_state_not_found'), + flowStateExpired('flow_state_expired'), + signupDisabled('signup_disabled'), + userBanned('user_banned'), + providerEmailNeedsVerification('provider_email_needs_verification'), + inviteNotFound('invite_not_found'), + badOauthState('bad_oauth_state'), + badOauthCallback('bad_oauth_callback'), + oauthProviderNotSupported('oauth_provider_not_supported'), + unexpectedAudience('unexpected_audience'), + singleIdentityNotDeletable('single_identity_not_deletable'), + emailConflictIdentityNotDeletable('email_conflict_identity_not_deletable'), + identityAlreadyExists('identity_already_exists'), + emailProviderDisabled('email_provider_disabled'), + phoneProviderDisabled('phone_provider_disabled'), + tooManyEnrolledMfaFactors('too_many_enrolled_mfa_factors'), + mfaFactorNameConflict('mfa_factor_name_conflict'), + mfaFactorNotFound('mfa_factor_not_found'), + mfaIpAddressMismatch('mfa_ip_address_mismatch'), + mfaChallengeExpired('mfa_challenge_expired'), + mfaVerificationFailed('mfa_verification_failed'), + mfaVerificationRejected('mfa_verification_rejected'), + insufficientAal('insufficient_aal'), + captchaFailed('captcha_failed'), + samlProviderDisabled('saml_provider_disabled'), + manualLinkingDisabled('manual_linking_disabled'), + smsSendFailed('sms_send_failed'), + emailNotConfirmed('email_not_confirmed'), + phoneNotConfirmed('phone_not_confirmed'), + reauthNonceMissing('reauth_nonce_missing'), + samlRelayStateNotFound('saml_relay_state_not_found'), + samlRelayStateExpired('saml_relay_state_expired'), + samlIdpNotFound('saml_idp_not_found'), + samlAssertionNoUserId('saml_assertion_no_user_id'), + samlAssertionNoEmail('saml_assertion_no_email'), + userAlreadyExists('user_already_exists'), + ssoProviderNotFound('sso_provider_not_found'), + samlMetadataFetchFailed('saml_metadata_fetch_failed'), + samlIdpAlreadyExists('saml_idp_already_exists'), + ssoDomainAlreadyExists('sso_domain_already_exists'), + samlEntityIdMismatch('saml_entity_id_mismatch'), + conflict('conflict'), + providerDisabled('provider_disabled'), + userSsoManaged('user_sso_managed'), + reauthenticationNeeded('reauthentication_needed'), + samePassword('same_password'), + reauthenticationNotValid('reauthentication_not_valid'), + otpExpired('otp_expired'), + otpDisabled('otp_disabled'), + identityNotFound('identity_not_found'), + weakPassword('weak_password'), + overRequestRateLimit('over_request_rate_limit'), + overEmailSendRateLimit('over_email_send_rate_limit'), + overSmsSendRateLimit('over_sms_send_rate_limit'), + badCodeVerifier('bad_code_verifier'), + anonymousProviderDisabled('anonymous_provider_disabled'), + hookTimeout('hook_timeout'), + hookTimeoutAfterRetry('hook_timeout_after_retry'), + hookPayloadOverSizeLimit('hook_payload_over_size_limit'), + hookPayloadUnknownSize('hook_payload_unknown_size'), + requestTimeout('request_timeout'), + mfaPhoneEnrollDisabled('mfa_phone_enroll_not_enabled'), + mfaPhoneVerifyDisabled('mfa_phone_verify_not_enabled'), + mfaTotpEnrollDisabled('mfa_totp_enroll_not_enabled'), + mfaTotpVerifyDisabled('mfa_totp_verify_not_enabled'); + + final String code; + const ErrorCode(this.code); + + static ErrorCode? fromCode(String code) { + return ErrorCode.values.firstWhereOrNull( + (value) => value.code == code, + ); + } +} diff --git a/packages/gotrue/test/api_version_test.dart b/packages/gotrue/test/api_version_test.dart new file mode 100644 index 00000000..82509cf6 --- /dev/null +++ b/packages/gotrue/test/api_version_test.dart @@ -0,0 +1,42 @@ +import 'package:gotrue/src/constants.dart'; +import 'package:gotrue/src/types/api_version.dart'; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +void main() { + group('ApiVersion', () { + test('should return non null object for valid header', () { + final String validHeader = '2024-01-01'; + final Response response = Response('', 200, headers: { + Constants.apiVersionHeaderName: validHeader, + }); + final version = ApiVersion.fromResponse(response); + expect(version?.name, validHeader); + expect(version?.timestamp, DateTime.parse('2024-01-01 00:00:00.000Z')); + }); + + test('should return null object for invalid header', () { + final List invalidValues = [ + '', + 'notadate', + 'Sat Feb 24 2024 17:59:17 GMT+0100', + '1990-01-01', + '2024-01-32', + ]; + + for (final value in invalidValues) { + final Response response = Response('', 200, headers: { + Constants.apiVersionHeaderName: value, + }); + final version = ApiVersion.fromResponse(response); + expect(version, isNull); + } + }); + + test('should return null object for no header', () { + final Response response = Response('', 200); + final version = ApiVersion.fromResponse(response); + expect(version, isNull); + }); + }); +} diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index 79951b76..70f892a0 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:dotenv/dotenv.dart'; import 'package:gotrue/gotrue.dart'; +import 'package:gotrue/src/types/error_code.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; import 'package:jwt_decode/jwt_decode.dart'; @@ -102,13 +103,16 @@ void main() { expect(data?.user.id, isA()); expect(data?.user.userMetadata!['Hello'], 'World'); }); - test('signUp() with week password throws AuthWeakPasswordException', + test('signUp() with weak password throws AuthWeakPasswordException', () async { try { await client.signUp(email: newEmail, password: '123'); - fail('signUp with week password should throw exception'); - } catch (error) { + 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'); } }); @@ -138,6 +142,7 @@ void main() { } on AuthException catch (error) { expect(error.message, errorMessage); expect(error.statusCode, '401'); + expect(error.code, 'unauthorized_client'); } catch (error) { fail( 'getSessionFromUrl threw ${error.runtimeType} instead of AuthException'); @@ -243,7 +248,7 @@ void main() { expect(user.appMetadata['provider'], 'email'); }); - test('signInWithPassword() with phone', () async { + test('signInWithPassword() with phone', () async { final response = await client.signInWithPassword(phone: phone1, password: password); final data = response.session; @@ -275,6 +280,17 @@ void main() { expect(newClient.currentSession?.accessToken ?? '', isNotEmpty); }); + test( + 'Set session with an empty refresh token throws AuthSessionMissingException', + () async { + try { + await client.setSession(''); + fail('setSession did not throw'); + } catch (error) { + expect(error, isA()); + } + }); + test('Update user', () async { await client.signInWithPassword(email: email1, password: password); @@ -295,6 +311,16 @@ void main() { expect(user?.userMetadata?['arabic'], 'عربى'); }); + test('Update user with the same password throws AuthException', () async { + await client.signInWithPassword(email: email1, password: password); + try { + await client.updateUser(UserAttributes(password: password)); + fail('updateUser did not throw'); + } on AuthException catch (error) { + expect(error.code, ErrorCode.samePassword.code); + } + }); + test('signOut', () async { await client.signInWithPassword(email: email1, password: password); expect(client.currentUser, isNotNull); diff --git a/packages/gotrue/test/custom_http_client.dart b/packages/gotrue/test/custom_http_client.dart index fe1f04d1..a4ee349e 100644 --- a/packages/gotrue/test/custom_http_client.dart +++ b/packages/gotrue/test/custom_http_client.dart @@ -146,3 +146,25 @@ class RetryTestHttpClient extends BaseClient { ); } } + +class MockedHttpClient extends BaseClient { + MockedHttpClient( + this.response, { + this.headers = const {}, + this.statusCode = 200, + }); + + final Map response; + final Map headers; + final int statusCode; + + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode(response))), + statusCode, + request: request, + headers: headers, + ); + } +} diff --git a/packages/gotrue/test/fetch_test.dart b/packages/gotrue/test/fetch_test.dart new file mode 100644 index 00000000..1a10b6f2 --- /dev/null +++ b/packages/gotrue/test/fetch_test.dart @@ -0,0 +1,83 @@ +import 'package:gotrue/gotrue.dart'; +import 'package:gotrue/src/constants.dart'; +import 'package:gotrue/src/fetch.dart'; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +import 'custom_http_client.dart'; + +const String _mockUrl = 'http://localhost'; +void main() { + group('GotrueFetch', () { + test('without API version and error code', () async { + final client = MockedHttpClient( + { + 'code': 400, + 'msg': 'error_message', + 'error_code': 'weak_password', + }, + statusCode: 400, + ); + await _testFetchRequest(client); + }); + + test('without API version and weak password error code with payload', + () async { + final client = MockedHttpClient( + { + 'code': 400, + 'msg': 'error_message', + 'error_code': 'weak_password', + 'weak_password': { + 'reasons': ['characters'], + }, + }, + statusCode: 400, + ); + await _testFetchRequest(client); + }); + + test('without API version, no error code and weak_password payload', + () async { + final client = MockedHttpClient( + { + 'msg': 'error_message', + 'weak_password': { + 'reasons': ['characters'], + }, + }, + statusCode: 400, + ); + await _testFetchRequest(client); + }); + + test('with API version 2024-01-01 and error code', () async { + final client = MockedHttpClient( + { + 'code': 'weak_password', + 'message': 'error_message', + 'weak_password': { + 'reasons': ['characters'], + }, + }, + headers: { + Constants.apiVersionHeaderName: '2024-01-01', + }, + statusCode: 400, + ); + await _testFetchRequest(client); + }); + }); +} + +Future _testFetchRequest(Client client) async { + final GotrueFetch fetch = GotrueFetch(client); + try { + await fetch.request(_mockUrl, RequestMethodType.get); + } on AuthException catch (error) { + expect(error.code, 'weak_password'); + expect(error.message, 'error_message'); + } catch (error) { + fail('Should have thrown AuthException'); + } +}