diff --git a/lib/app/dependencies_provider.dart b/lib/app/dependencies_provider.dart index 6c61dc3..6985b50 100644 --- a/lib/app/dependencies_provider.dart +++ b/lib/app/dependencies_provider.dart @@ -1,11 +1,9 @@ import 'dart:async'; -import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/core/network_request_interceptor.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v1.swagger.dart' - hide $JsonSerializableConverter; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart'; +import 'package:cafe_analog_app/http/http.dart'; +import 'package:cafe_analog_app/login/bloc/auth_cubit_handle.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/auth_token_store.dart'; import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; import 'package:cafe_analog_app/login/data/login_repository.dart'; import 'package:chopper/chopper.dart'; @@ -34,20 +32,16 @@ class DependenciesProvider extends StatelessWidget { RepositoryProvider.value(value: localStorage), RepositoryProvider(create: (_) => const FlutterSecureStorage()), RepositoryProvider(create: (_) => AuthTokenStore()), - - // Http + RepositoryProvider(create: (_) => AuthCubitHandle()), RepositoryProvider( - create: (context) => ChopperClient( - baseUrl: Uri.parse('https://core.dev.analogio.dk'), - interceptors: [ - NetworkRequestInterceptor(authTokenStore: context.read()), - ], - converter: $JsonSerializableConverter(), - services: [CoffeecardApiV1.create(), CoffeecardApiV2.create()], - // FIXME(marfavi): Add authenticator to redirect on 401 responses - // authenticator: sl.get(), + create: (context) => AuthTokenRepository( + secureStorage: context.read(), + authTokenStore: context.read(), ), ), + + // Http + RepositoryProvider(create: makeHttpClient), RepositoryProvider( create: (context) => context.read().getService(), @@ -69,12 +63,6 @@ class DependenciesProvider extends StatelessWidget { RepositoryProvider( create: (context) => LoginRepository(executor: context.read()), ), - RepositoryProvider( - create: (context) => AuthTokenRepository( - secureStorage: context.read(), - authTokenStore: context.read(), - ), - ), ], child: MultiBlocProvider( providers: [ @@ -85,6 +73,7 @@ class DependenciesProvider extends StatelessWidget { loginRepository: context.read(), ); unawaited(authCubit.start()); + context.read().bind(authCubit); return authCubit; }, ), diff --git a/lib/core/failures.dart b/lib/core/failures.dart index 9782b57..098f98e 100644 --- a/lib/core/failures.dart +++ b/lib/core/failures.dart @@ -21,7 +21,7 @@ sealed class NetworkFailure extends Failure { } class ServerFailure extends NetworkFailure { - const ServerFailure(super.reason, this.statuscode); + const ServerFailure(super.reason, this.statusCode); factory ServerFailure.fromResponse(Response response) { try { @@ -30,15 +30,18 @@ class ServerFailure extends NetworkFailure { final message = jsonString['message'] as String?; return ServerFailure( - message ?? 'An unknown error occured', + '${message ?? 'An unknown error occurred'} (${response.statusCode})', response.statusCode, ); } on Exception { - return ServerFailure('An unknown error occured', response.statusCode); + return ServerFailure( + 'An unknown error occurred (${response.statusCode})', + response.statusCode, + ); } } - final int statuscode; + final int statusCode; } class ConnectionFailure extends NetworkFailure { diff --git a/lib/core/widgets/app_bar.dart b/lib/core/widgets/app_bar.dart index ba286a3..456c197 100644 --- a/lib/core/widgets/app_bar.dart +++ b/lib/core/widgets/app_bar.dart @@ -1,5 +1,7 @@ +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class AnalogAppBar extends StatelessWidget implements PreferredSizeWidget { const AnalogAppBar({ @@ -34,6 +36,54 @@ class AnalogAppBar extends StatelessWidget implements PreferredSizeWidget { onBrightnessChanged?.call(newBrightness); }, ), + if (kDebugMode) + IconButton( + icon: const Icon(Icons.construction_rounded), + tooltip: 'Invalidate JWT', + onPressed: () async { + final invalidateRefreshToken = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Invalidate tokens'), + content: const Text( + 'Do you want to invalidate only the JWT ' + 'or also the refresh token?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('JWT only'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('JWT + refresh'), + ), + ], + ), + ); + + if (invalidateRefreshToken == null) return; + if (!context.mounted) return; + + final result = await context + .read() + .invalidateJwt(invalidateRefreshToken: invalidateRefreshToken) + .run(); + + if (!context.mounted) return; + + final message = result.match( + (failure) => 'Failed to invalidate JWT: ${failure.reason}', + (_) => invalidateRefreshToken + ? 'JWT and refresh token invalidated.' + : 'JWT invalidated (refresh token preserved).', + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + }, + ), ], ); } diff --git a/lib/http/http.dart b/lib/http/http.dart new file mode 100644 index 0000000..669665a --- /dev/null +++ b/lib/http/http.dart @@ -0,0 +1,9 @@ +export 'package:cafe_analog_app/generated/api/client_index.dart'; +export 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; +export 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; +export 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart'; + +export 'make_http_client.dart'; +export 'network_request_executor.dart'; +export 'network_request_interceptor.dart'; +export 'token_refresh_authenticator.dart'; diff --git a/lib/http/make_http_client.dart b/lib/http/make_http_client.dart new file mode 100644 index 0000000..367b94a --- /dev/null +++ b/lib/http/make_http_client.dart @@ -0,0 +1,30 @@ +import 'package:cafe_analog_app/http/http.dart'; +import 'package:chopper/chopper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const String _baseUrl = 'https://core.dev.analogio.dk'; + +/// Creates and configures the [ChopperClient]s used for network requests. +ChopperClient makeHttpClient(BuildContext context) { + // Separate http client only used to refresh token requests. + // Calls done from this client won't trigger the authenticator + // (token refresh logic) or interceptor (injecting jwt token in auth header). + final tokenRefreshClient = ChopperClient( + baseUrl: Uri.parse(_baseUrl), + converter: $JsonSerializableConverter(), + services: [CoffeecardApiV2.create()], + ); + + return ChopperClient( + baseUrl: Uri.parse(_baseUrl), + converter: $JsonSerializableConverter(), + services: [CoffeecardApiV1.create(), CoffeecardApiV2.create()], + interceptors: [NetworkRequestInterceptor(authTokenStore: context.read())], + authenticator: TokenRefreshAuthenticator( + authTokenRepository: context.read(), + tokenRefreshApi: tokenRefreshClient.getService(), + authCubitHandle: context.read(), + ), + ); +} diff --git a/lib/core/network_request_executor.dart b/lib/http/network_request_executor.dart similarity index 96% rename from lib/core/network_request_executor.dart rename to lib/http/network_request_executor.dart index 2e9c919..221cff1 100644 --- a/lib/core/network_request_executor.dart +++ b/lib/http/network_request_executor.dart @@ -1,5 +1,5 @@ import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/generated/api/client_index.dart'; +import 'package:cafe_analog_app/http/http.dart'; import 'package:chopper/chopper.dart' show Response; import 'package:fpdart/fpdart.dart'; import 'package:logger/logger.dart'; diff --git a/lib/core/network_request_interceptor.dart b/lib/http/network_request_interceptor.dart similarity index 73% rename from lib/core/network_request_interceptor.dart rename to lib/http/network_request_interceptor.dart index 8df2804..6a1e17f 100644 --- a/lib/core/network_request_interceptor.dart +++ b/lib/http/network_request_interceptor.dart @@ -1,11 +1,10 @@ import 'dart:async'; +import 'package:cafe_analog_app/login/data/auth_token_store.dart'; import 'package:chopper/chopper.dart'; -class AuthTokenStore { - String? token; -} - +/// An interceptor that adds the Authorization header with the JWT token +/// to outgoing HTTP requests if a token is available in the [AuthTokenStore]. class NetworkRequestInterceptor implements Interceptor { NetworkRequestInterceptor({ required AuthTokenStore authTokenStore, diff --git a/lib/http/token_refresh_authenticator.dart b/lib/http/token_refresh_authenticator.dart new file mode 100644 index 0000000..4a1e5ff --- /dev/null +++ b/lib/http/token_refresh_authenticator.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:cafe_analog_app/http/http.dart'; +import 'package:cafe_analog_app/login/bloc/auth_cubit_handle.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:chopper/chopper.dart'; + +class TokenRefreshAuthenticator extends Authenticator { + TokenRefreshAuthenticator({ + required AuthTokenRepository authTokenRepository, + required CoffeecardApiV2 tokenRefreshApi, + required AuthCubitHandle authCubitHandle, + }) : _authTokenRepository = authTokenRepository, + _tokenRefreshApi = tokenRefreshApi, + _authCubitHandle = authCubitHandle; + + /// This header is added to requests that have already been retried after a + /// token refresh. Prevents infinite retry loops in case the new token is also + /// invalid for some reason. + static const _retryHeader = 'X-Auth-Retry'; + + final AuthTokenRepository _authTokenRepository; + final CoffeecardApiV2 _tokenRefreshApi; + final AuthCubitHandle _authCubitHandle; + Completer? _refreshCompleter; + + @override + FutureOr authenticate( + Request request, + Response response, [ + Request? _, + ]) async { + if (response.statusCode != 401) return null; + if (request.headers[_retryHeader] == 'true') return null; + if (request.url.path.endsWith('/api/v2/account/auth')) return null; + + log( + 'Received 401 response for request: ${request.url}, ' + 'attempting to refresh tokens...\n' + '-- Headers: ${request.headers}', + ); + + final refreshedTokens = await _refreshTokens(); + if (refreshedTokens == null) { + await _authCubitHandle.logOut(); + return null; + } + + final updatedRequest = applyHeader( + request, + 'Authorization', + 'Bearer ${refreshedTokens.jwt}', + ); + + return updatedRequest.copyWith( + headers: {...updatedRequest.headers, _retryHeader: 'true'}, + ); + } + + Future _refreshTokens() async { + if (_refreshCompleter != null) return _refreshCompleter!.future; + + final completer = Completer(); + _refreshCompleter = completer; + + try { + final tokensEither = await _authTokenRepository.getTokens().run(); + final existingTokens = tokensEither.match( + (_) => null, + (maybeTokens) => maybeTokens.match( + () => null, + (tokens) => tokens, + ), + ); + + if (existingTokens == null) { + log('Token refresh aborted: no tokens found in storage.'); + completer.complete(null); + return completer.future; + } + + final refreshResponse = await _tokenRefreshApi.accountAuthPost( + body: TokenLoginRequest(token: existingTokens.refreshToken), + ); + final responseBody = refreshResponse.body; + + if (!refreshResponse.isSuccessful || responseBody == null) { + log( + 'Token refresh failed: server responded with ' + '${refreshResponse.statusCode}.', + ); + completer.complete(null); + return completer.future; + } + + final newTokens = AuthTokens( + jwt: responseBody.jwt, + refreshToken: responseBody.refreshToken, + ); + + final savedTokens = await _authTokenRepository + .saveTokens(newTokens) + .match((_) => null, (tokens) => tokens) + .run(); + + if (savedTokens != null) { + log('Token refresh succeeded.'); + } else { + log('Token refresh succeeded but saving new tokens failed.'); + } + completer.complete(savedTokens); + return completer.future; + } on Exception catch (e) { + log('Token refresh failed with exception: $e.'); + completer.complete(null); + return completer.future; + } finally { + _refreshCompleter = null; + } + } +} diff --git a/lib/login/bloc/auth_cubit_handle.dart b/lib/login/bloc/auth_cubit_handle.dart new file mode 100644 index 0000000..27e5b79 --- /dev/null +++ b/lib/login/bloc/auth_cubit_handle.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/http/http.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:chopper/chopper.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Breaks the dependency cycle between the HTTP layer and the auth cubit. +/// +/// [ChopperClient] (that contains [TokenRefreshAuthenticator]) is wired up in +/// a [RepositoryProvider] that runs before the [BlocProvider] for [AuthCubit]. +/// This means the authenticator is constructed before a cubit instance exists, +/// so it cannot receive the cubit directly. +/// +/// Instead, both are given this shared handle. Once the [BlocProvider] creates +/// [AuthCubit] it immediately calls [bind], after which any call to [logOut] +/// (e.g. when token refresh fails and the user must be signed out) is +/// forwarded to the live cubit. The internal [Completer] guarantees that +/// calls arriving before [bind] has run are safely queued rather than dropped. +class AuthCubitHandle { + final _completer = Completer(); + AuthCubit? _cubit; + + void bind(AuthCubit cubit) { + _cubit = cubit; + if (!_completer.isCompleted) { + _completer.complete(cubit); + } + } + + Future logOut() async { + final cubit = _cubit ?? await _completer.future; + await cubit.logOut(); + } +} diff --git a/lib/login/bloc/authentication_cubit.dart b/lib/login/bloc/authentication_cubit.dart index dd31863..49110dd 100644 --- a/lib/login/bloc/authentication_cubit.dart +++ b/lib/login/bloc/authentication_cubit.dart @@ -89,10 +89,4 @@ class AuthCubit extends Cubit { emit(newState); } - - /// Refresh the JWT token. - Future refreshToken({required AuthTokens tokens}) async { - // FIXME(marfavi): implement token refresh logic - throw UnimplementedError(); - } } diff --git a/lib/login/data/auth_token_store.dart b/lib/login/data/auth_token_store.dart new file mode 100644 index 0000000..10470e8 --- /dev/null +++ b/lib/login/data/auth_token_store.dart @@ -0,0 +1,17 @@ +import 'package:cafe_analog_app/http/http.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// In-memory cache of the current JWT for use by the network layer. +/// +/// The canonical source of truth for tokens is [FlutterSecureStorage], but +/// reads from secure storage are asynchronous. The [NetworkRequestInterceptor] +/// runs synchronously inside Chopper's request pipeline, so it cannot `await` +/// a storage read on every outgoing request. +/// +/// [AuthTokenRepository] keeps this store up to date whenever tokens are +/// saved, refreshed, or cleared, ensuring the interceptor always has an +/// immediately available value to attach as the `Authorization` header. +class AuthTokenStore { + String? token; +} diff --git a/lib/login/data/authentication_token_repository.dart b/lib/login/data/authentication_token_repository.dart index 51804aa..dd9897d 100644 --- a/lib/login/data/authentication_token_repository.dart +++ b/lib/login/data/authentication_token_repository.dart @@ -1,5 +1,5 @@ import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/core/network_request_interceptor.dart'; +import 'package:cafe_analog_app/login/data/auth_token_store.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:fpdart/fpdart.dart'; @@ -70,4 +70,30 @@ class AuthTokenRepository { (error, _) => LocalStorageFailure('Failed to clear auth tokens: $error'), ); } + + // TODO(marfavi): Remove this method after properly handling token refresh + // and logout in the app. + /// Replaces the stored JWT with an invalid token. + /// + /// If [invalidateRefreshToken] is true, the refresh token is also replaced + /// with an invalid value (not deleted). + TaskEither invalidateJwt({ + bool invalidateRefreshToken = false, + }) { + return TaskEither.tryCatch( + () async { + await _secureStorage.write(key: _jwtKey, value: 'invalid-jwt'); + if (invalidateRefreshToken) { + await _secureStorage.write( + key: _refreshTokenKey, + value: 'invalid-refresh', + ); + } + _authTokenStore.token = 'invalid-jwt'; + return unit; + }, + (error, _) => + LocalStorageFailure('Failed to invalidate auth token: $error'), + ); + } } diff --git a/lib/login/data/login_repository.dart b/lib/login/data/login_repository.dart index 8d28672..203341e 100644 --- a/lib/login/data/login_repository.dart +++ b/lib/login/data/login_repository.dart @@ -1,7 +1,5 @@ import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; +import 'package:cafe_analog_app/http/http.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:fpdart/fpdart.dart'; diff --git a/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart b/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart index f08d78d..24553f1 100644 --- a/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart +++ b/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart @@ -1,6 +1,5 @@ import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; +import 'package:cafe_analog_app/http/http.dart'; import 'package:fpdart/fpdart.dart'; class OwnedTicketsRemoteDataProvider { diff --git a/pubspec.lock b/pubspec.lock index 8dc076d..57c317f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -406,7 +406,7 @@ packages: source: hosted version: "1.0.1" http: - dependency: transitive + dependency: "direct dev" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 9602b8d..37b1b76 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dev_dependencies: json_serializable: ^6.8.0 # swagger generator swagger_dart_code_generator: ^4.1.1 # swagger generator build_verify: ^3.1.1 + http: ^1.6.0 flutter: generate: true diff --git a/test/http/token_refresh_authenticator_test.dart b/test/http/token_refresh_authenticator_test.dart new file mode 100644 index 0000000..1a00bf2 --- /dev/null +++ b/test/http/token_refresh_authenticator_test.dart @@ -0,0 +1,304 @@ +import 'package:cafe_analog_app/http/http.dart'; +import 'package:cafe_analog_app/login/bloc/auth_cubit_handle.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:chopper/chopper.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; + +class _MockAuthTokenRepository extends Mock implements AuthTokenRepository {} + +class _MockCoffeecardApiV2 extends Mock implements CoffeecardApiV2 {} + +class _MockAuthCubitHandle extends Mock implements AuthCubitHandle {} + +/// Builds a minimal Chopper [Request] for the given path. +Request _buildRequest(String path, {Map headers = const {}}) { + return Request( + 'GET', + Uri.parse('https://core.dev.analogio.dk$path'), + Uri.parse('https://core.dev.analogio.dk'), + headers: headers, + ); +} + +/// Builds a Chopper [Response] with the given status code. +Response _buildResponse(int statusCode) { + return Response( + http.StreamedResponse(const Stream.empty(), statusCode), + null, + ); +} + +/// Builds a successful [Response]. +Response _buildRefreshSuccessResponse({ + required String jwt, + required String refreshToken, +}) { + final body = UserLoginResponse(jwt: jwt, refreshToken: refreshToken); + return Response(http.StreamedResponse(const Stream.empty(), 200), body); +} + +/// Builds a failed [Response]. +Response _buildRefreshFailureResponse(int statusCode) { + return Response( + http.StreamedResponse(const Stream.empty(), statusCode), + null, + ); +} + +void main() { + late _MockAuthTokenRepository authTokenRepository; + late _MockCoffeecardApiV2 tokenRefreshApi; + late _MockAuthCubitHandle authCubitHandle; + late TokenRefreshAuthenticator authenticator; + + const existingTokens = AuthTokens(jwt: 'old-jwt', refreshToken: 'old-ref'); + const newTokens = AuthTokens(jwt: 'new-jwt', refreshToken: 'new-ref'); + + setUp(() { + authTokenRepository = _MockAuthTokenRepository(); + tokenRefreshApi = _MockCoffeecardApiV2(); + authCubitHandle = _MockAuthCubitHandle(); + + authenticator = TokenRefreshAuthenticator( + authTokenRepository: authTokenRepository, + tokenRefreshApi: tokenRefreshApi, + authCubitHandle: authCubitHandle, + ); + }); + + group('TokenRefreshAuthenticator', () { + group('ignores non-401 responses', () { + for (final code in [200, 400, 403, 500]) { + test('returns null for $code', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(code), + ); + expect(result, isNull); + verifyZeroInteractions(authTokenRepository); + verifyZeroInteractions(tokenRefreshApi); + verifyZeroInteractions(authCubitHandle); + }); + } + }); + + test('ignores requests already marked with retry header', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/tickets', headers: {'X-Auth-Retry': 'true'}), + _buildResponse(401), + ); + expect(result, isNull); + verifyZeroInteractions(authTokenRepository); + }); + + test('ignores auth endpoint to prevent refresh loops', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/account/auth'), + _buildResponse(401), + ); + expect(result, isNull); + verifyZeroInteractions(authTokenRepository); + }); + + group('on 401 with successful token refresh', () { + setUp(() { + when( + () => authTokenRepository.getTokens(), + ).thenReturn(TaskEither.right(some(existingTokens))); + + when( + () => tokenRefreshApi.accountAuthPost( + body: TokenLoginRequest(token: existingTokens.refreshToken), + ), + ).thenAnswer( + (_) async => _buildRefreshSuccessResponse( + jwt: newTokens.jwt, + refreshToken: newTokens.refreshToken, + ), + ); + + when( + () => authTokenRepository.saveTokens(newTokens), + ).thenReturn(TaskEither.right(newTokens)); + }); + + test('retries the request with the new JWT', () async { + final original = _buildRequest('/api/v2/tickets'); + final result = await authenticator.authenticate( + original, + _buildResponse(401), + ); + + expect(result, isNotNull); + expect(result!.headers['Authorization'], 'Bearer ${newTokens.jwt}'); + }); + + test('adds retry header to prevent infinite loops', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + + expect(result!.headers['X-Auth-Retry'], 'true'); + }); + + test('saves the new tokens', () async { + await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + + verify(() => authTokenRepository.saveTokens(newTokens)).called(1); + }); + + test('does not call logOut', () async { + await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + + verifyNever(() => authCubitHandle.logOut()); + }); + }); + + group('on 401 with no stored tokens', () { + setUp(() { + when( + () => authTokenRepository.getTokens(), + ).thenReturn(TaskEither.right(none())); + + when(() => authCubitHandle.logOut()).thenAnswer((_) async {}); + }); + + test('returns null', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + expect(result, isNull); + }); + + test('calls logOut on the cubit handle', () async { + await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + verify(() => authCubitHandle.logOut()).called(1); + }); + + test('does not call the refresh API', () async { + await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + verifyZeroInteractions(tokenRefreshApi); + }); + }); + + group('on 401 with failed refresh response from server', () { + setUp(() { + when( + () => authTokenRepository.getTokens(), + ).thenReturn(TaskEither.right(some(existingTokens))); + + when( + () => tokenRefreshApi.accountAuthPost(body: any(named: 'body')), + ).thenAnswer((_) async => _buildRefreshFailureResponse(401)); + + when(() => authCubitHandle.logOut()).thenAnswer((_) async {}); + }); + + test('returns null', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + expect(result, isNull); + }); + + test('calls logOut on the cubit handle', () async { + await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + verify(() => authCubitHandle.logOut()).called(1); + }); + }); + + group('on 401 with exception during token refresh', () { + setUp(() { + when( + () => authTokenRepository.getTokens(), + ).thenReturn(TaskEither.right(some(existingTokens))); + + when( + () => tokenRefreshApi.accountAuthPost(body: any(named: 'body')), + ).thenThrow(Exception('Network error')); + + when(() => authCubitHandle.logOut()).thenAnswer((_) async {}); + }); + + test('returns null', () async { + final result = await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + expect(result, isNull); + }); + + test('calls logOut on the cubit handle', () async { + await authenticator.authenticate( + _buildRequest('/api/v2/tickets'), + _buildResponse(401), + ); + verify(() => authCubitHandle.logOut()).called(1); + }); + }); + + group('concurrent 401 responses', () { + test('only calls the refresh API once', () async { + when( + () => authTokenRepository.getTokens(), + ).thenReturn(TaskEither.right(some(existingTokens))); + + when( + () => tokenRefreshApi.accountAuthPost(body: any(named: 'body')), + ).thenAnswer((_) async { + // Simulate network latency so second call arrives while first is + // in-flight. + await Future.delayed(const Duration(milliseconds: 10)); + return _buildRefreshSuccessResponse( + jwt: newTokens.jwt, + refreshToken: newTokens.refreshToken, + ); + }); + + when( + () => authTokenRepository.saveTokens(newTokens), + ).thenReturn(TaskEither.right(newTokens)); + + final request = _buildRequest('/api/v2/tickets'); + final response = _buildResponse(401); + + // Fire two authenticate calls without awaiting the first. + final results = await Future.wait([ + Future.value(authenticator.authenticate(request, response)), + Future.value(authenticator.authenticate(request, response)), + ]); + + // Both requests should succeed with the new JWT. + expect(results[0]!.headers['Authorization'], 'Bearer ${newTokens.jwt}'); + expect(results[1]!.headers['Authorization'], 'Bearer ${newTokens.jwt}'); + + // Refresh endpoint called exactly once despite two concurrent 401s. + verify( + () => tokenRefreshApi.accountAuthPost(body: any(named: 'body')), + ).called(1); + }); + }); + }); +} diff --git a/test/router_test.dart b/test/router_test.dart index 2604359..1a60b24 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:cafe_analog_app/app/router.dart'; import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/core/network_request_executor.dart'; import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; +import 'package:cafe_analog_app/http/http.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:cafe_analog_app/login/ui/authentication_navigator.dart';