Skip to content

Commit

Permalink
Merge pull request #930 from SocialGouv/feat/401-handling
Browse files Browse the repository at this point in the history
feat(interceptors): add UnauthorizedInterceptor to handle 401 errors
  • Loading branch information
Alwein authored Jan 27, 2025
2 parents aee7723 + 2f6d0ec commit def209b
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 4 deletions.
10 changes: 8 additions & 2 deletions lib/app_initializer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:pass_emploi_app/configuration/configuration.dart';
import 'package:pass_emploi_app/crashlytics/crashlytics.dart';
import 'package:pass_emploi_app/features/mode_demo/is_mode_demo_repository.dart';
import 'package:pass_emploi_app/network/cache_manager.dart';
import 'package:pass_emploi_app/network/interceptors/logout_after_too_many_401_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/monitoring_interceptor.dart';
import 'package:pass_emploi_app/network/pass_emploi_dio_builder.dart';
import 'package:pass_emploi_app/pages/force_update_page.dart';
Expand Down Expand Up @@ -192,8 +193,11 @@ class AppInitializer {
final requestCacheManager = PassEmploiCacheManager(cacheStore, configuration.serverBaseUrl);
final modeDemoRepository = ModeDemoRepository();
final baseUrl = configuration.serverBaseUrl;
final monitoringInterceptor =
MonitoringInterceptor(InstallationIdRepository(securedPreferences), AppVersionRepository());
final monitoringInterceptor = MonitoringInterceptor(
InstallationIdRepository(securedPreferences),
AppVersionRepository(),
);
final unauthorizedInterceptor = LogoutAfterTooMany401Interceptor(remoteConfigRepository);
_setTrustedCertificatesForOldDevices(configuration, crashlytics);
final dioClient = PassEmploiDioBuilder(
baseUrl: baseUrl,
Expand All @@ -202,6 +206,7 @@ class AppInitializer {
accessTokenRetriever: accessTokenRetriever,
authAccessChecker: authAccessChecker,
monitoringInterceptor: monitoringInterceptor,
unauthorizedInterceptor: unauthorizedInterceptor,
).build();
logoutRepository.setHttpClient(dioClient);
logoutRepository.setCacheManager(requestCacheManager);
Expand Down Expand Up @@ -299,6 +304,7 @@ class AppInitializer {
).initializeReduxStore(initialState: AppState.initialState(configuration: configuration));
accessTokenRetriever.setStore(reduxStore);
authAccessChecker.setStore(reduxStore);
unauthorizedInterceptor.setStore(reduxStore);
monitoringInterceptor.setStore(reduxStore);
chatCrypto.setStore(reduxStore);
await pushNotificationManager.init(reduxStore);
Expand Down
2 changes: 1 addition & 1 deletion lib/features/login/login_actions.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:pass_emploi_app/models/login_mode.dart';
import 'package:pass_emploi_app/models/user.dart';

enum LogoutReason { userLogout, apiResponse401, expiredRefreshToken, accountSuppression, cguRefused }
enum LogoutReason { userLogout, apiResponse401, expiredRefreshToken, accountSuppression, cguRefused, tooMany401 }

extension LoginModeModeExtension on LoginMode {
bool isDemo() => this == LoginMode.DEMO_PE || this == LoginMode.DEMO_MILO;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'package:dio/dio.dart';
import 'package:pass_emploi_app/features/login/login_actions.dart';
import 'package:pass_emploi_app/network/interceptors/pass_emploi_base_interceptor.dart';
import 'package:pass_emploi_app/redux/app_state.dart';
import 'package:pass_emploi_app/repositories/remote_config_repository.dart';
import 'package:redux/redux.dart';

class LogoutAfterTooMany401Interceptor extends PassEmploiBaseInterceptor {
final RemoteConfigRepository _remoteConfigRepository;

LogoutAfterTooMany401Interceptor(RemoteConfigRepository remoteConfigRepository)
: _remoteConfigRepository = remoteConfigRepository;

late final Store<AppState> _store;
int unauthorizedCount = 0;

@override
void onPassEmploiError(DioException err, ErrorInterceptorHandler handler) {
final maxUnauthorizedErrorsBeforeLogout = _remoteConfigRepository.maxUnauthorizedErrorsBeforeLogout();

if (maxUnauthorizedErrorsBeforeLogout == null) {
handler.next(err);
return;
}

if (err.response?.statusCode == 401) {
unauthorizedCount++;
if (unauthorizedCount >= maxUnauthorizedErrorsBeforeLogout) {
_onUnauthorizedErrorCountExceeded();
}
} else {
unauthorizedCount = 0;
}
handler.next(err);
}

void _onUnauthorizedErrorCountExceeded() {
_store.dispatch(RequestLogoutAction(LogoutReason.tooMany401));
}

void setStore(Store<AppState> store) => _store = store;
}
6 changes: 5 additions & 1 deletion lib/network/pass_emploi_dio_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:pass_emploi_app/network/interceptors/cache_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/demo_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/expired_token_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/logging_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/logout_after_too_many_401_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/monitoring_interceptor.dart';

class PassEmploiDioBuilder {
Expand All @@ -18,6 +19,7 @@ class PassEmploiDioBuilder {
final AuthAccessTokenRetriever accessTokenRetriever;
final AuthAccessChecker authAccessChecker;
final MonitoringInterceptor monitoringInterceptor;
final LogoutAfterTooMany401Interceptor unauthorizedInterceptor;

PassEmploiDioBuilder({
required this.baseUrl,
Expand All @@ -26,6 +28,7 @@ class PassEmploiDioBuilder {
required this.accessTokenRetriever,
required this.authAccessChecker,
required this.monitoringInterceptor,
required this.unauthorizedInterceptor,
});

Dio build() {
Expand All @@ -42,7 +45,8 @@ class PassEmploiDioBuilder {
..add(AuthInterceptor(accessTokenRetriever))
..add(CacheInterceptor(DioCacheInterceptor(options: cacheOptions)))
..add(LoggingNetworkInterceptor())
..add(ExpiredTokenInterceptor(authAccessChecker));
..add(ExpiredTokenInterceptor(authAccessChecker))
..add(unauthorizedInterceptor);
return dioClient;
}
}
7 changes: 7 additions & 0 deletions lib/repositories/remote_config_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class RemoteConfigRepository {
return value > 0 ? value : null;
}

int? maxUnauthorizedErrorsBeforeLogout() {
if (_firebaseRemoteConfig == null) return null;

final value = _firebaseRemoteConfig.getInt("app_max_unauthorized_errors_before_logout");
return value > 0 ? value : null;
}

int monSuiviPoleEmploiStartDateInMonths() {
if (_firebaseRemoteConfig == null) return 0;
return _firebaseRemoteConfig.getInt("mon_suivi_ft_start_date_in_months");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pass_emploi_app/features/login/login_actions.dart';
import 'package:pass_emploi_app/network/interceptors/logout_after_too_many_401_interceptor.dart';
import 'package:pass_emploi_app/redux/app_state.dart';
import 'package:pass_emploi_app/repositories/remote_config_repository.dart';
import 'package:redux/redux.dart';

import '../../doubles/spies.dart';

class MockStore extends Mock implements Store<AppState> {}

class MockInterceptorHandler extends Mock implements ErrorInterceptorHandler {}

class MockRemoteConfigRepository extends Mock implements RemoteConfigRepository {
void withUnhautorizedLimitAt(int? limit) {
when(() => maxUnauthorizedErrorsBeforeLogout()).thenReturn(limit);
}
}

void main() {
late LogoutAfterTooMany401Interceptor interceptor;
late MockInterceptorHandler interceptorHandler;
late MockRemoteConfigRepository remoteConfigRepository;
late MockStore mockStore;

setUp(() {
remoteConfigRepository = MockRemoteConfigRepository();
interceptor = LogoutAfterTooMany401Interceptor(remoteConfigRepository);
interceptorHandler = MockInterceptorHandler();
mockStore = MockStore();
});

test('should do nothing when unauthorized limit is null', () {
// Given
remoteConfigRepository.withUnhautorizedLimitAt(null);
final dioError = DioException(
requestOptions: RequestOptions(path: '/test'),
response: Response(statusCode: 401, requestOptions: RequestOptions(path: '/test')),
);
interceptor.setStore(mockStore);

// When
interceptor.onPassEmploiError(dioError, interceptorHandler);

// Then
expect(interceptor.unauthorizedCount, 0);
verifyNever(() => mockStore.dispatch(any));
});

test('increments unauthorizedCount on 401 error', () {
// Given
remoteConfigRepository.withUnhautorizedLimitAt(10);
final dioError = DioException(
requestOptions: RequestOptions(path: '/test'),
response: Response(statusCode: 401, requestOptions: RequestOptions(path: '/test')),
);
interceptor.setStore(mockStore);

// When
interceptor.onPassEmploiError(dioError, interceptorHandler);

// Then
expect(interceptor.unauthorizedCount, 1);
verifyNever(() => mockStore.dispatch(any));
});

test('dispatches RequestLogoutAction when limit is exceeded', () {
// Given
remoteConfigRepository.withUnhautorizedLimitAt(1);
final store = StoreSpy();
final dioError = DioException(
requestOptions: RequestOptions(path: '/test'),
response: Response(statusCode: 401, requestOptions: RequestOptions(path: '/test')),
);
interceptor.setStore(store);

// When
interceptor.onPassEmploiError(dioError, interceptorHandler);

// Then
expect(interceptor.unauthorizedCount, 1);
expect(store.dispatchedAction, isA<RequestLogoutAction>());
});

test('reset unauthorizedCount for non-401 errors', () {
// Given
remoteConfigRepository.withUnhautorizedLimitAt(10);
interceptor.unauthorizedCount = 5;
final dioError = DioException(
requestOptions: RequestOptions(path: '/test'),
response: Response(statusCode: 500, requestOptions: RequestOptions(path: '/test')),
);
interceptor.setStore(mockStore);

// When
interceptor.onPassEmploiError(dioError, interceptorHandler);

// Then
expect(interceptor.unauthorizedCount, 0);
verifyNever(() => mockStore.dispatch(any));
});
}
15 changes: 15 additions & 0 deletions test/network/pass_emploi_dio_builder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import 'package:pass_emploi_app/auth/auth_access_checker.dart';
import 'package:pass_emploi_app/auth/auth_access_token_retriever.dart';
import 'package:pass_emploi_app/features/mode_demo/is_mode_demo_repository.dart';
import 'package:pass_emploi_app/network/cache_manager.dart';
import 'package:pass_emploi_app/network/interceptors/logout_after_too_many_401_interceptor.dart';
import 'package:pass_emploi_app/network/interceptors/monitoring_interceptor.dart';
import 'package:pass_emploi_app/network/pass_emploi_dio_builder.dart';
import 'package:pass_emploi_app/redux/app_state.dart';
import 'package:pass_emploi_app/repositories/app_version_repository.dart';
import 'package:pass_emploi_app/repositories/installation_id_repository.dart';
import 'package:pass_emploi_app/repositories/remote_config_repository.dart';
import 'package:redux/redux.dart';

void main() {
Expand All @@ -23,7 +25,9 @@ void main() {
late MockModeDemoRepository modeDemoRepository;
late MockAuthAccessTokenRetriever accessTokenRetriever;
late MockAuthAccessChecker authAccessChecker;
late MockRemoteConfigRepository remoteConfigRepository;
late MonitoringInterceptor monitoringInterceptor;
late LogoutAfterTooMany401Interceptor unauthorizedInterceptor;

setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
Expand All @@ -32,13 +36,16 @@ void main() {
accessTokenRetriever = MockAuthAccessTokenRetriever();
authAccessChecker = MockAuthAccessChecker();
monitoringInterceptor = DummyMonitoringInterceptor();
remoteConfigRepository = MockRemoteConfigRepository();
unauthorizedInterceptor = LogoutAfterTooMany401Interceptor(remoteConfigRepository);
dio = PassEmploiDioBuilder(
baseUrl: "https://api.test.fr",
cacheStore: cacheStore,
modeDemoRepository: modeDemoRepository,
accessTokenRetriever: accessTokenRetriever,
authAccessChecker: authAccessChecker,
monitoringInterceptor: monitoringInterceptor,
unauthorizedInterceptor: unauthorizedInterceptor,
).build();
DioAdapter(dio: dio).onGet(path, (server) => server.reply(200, responseData));
});
Expand Down Expand Up @@ -119,6 +126,12 @@ class MockAuthAccessTokenRetriever extends Mock implements AuthAccessTokenRetrie

class MockAuthAccessChecker extends Mock implements AuthAccessChecker {}

class MockUnauthorizedInterceptor extends Mock implements LogoutAfterTooMany401Interceptor {
MockUnauthorizedInterceptor() {
setStore(DummyStore());
}
}

class DummyMonitoringInterceptor extends MonitoringInterceptor {
DummyMonitoringInterceptor() : super(MockInstallationIdRepository(), MockAppVersionRepository()) {
setStore(DummyStore());
Expand All @@ -140,3 +153,5 @@ class MockAppVersionRepository extends Mock implements AppVersionRepository {
class DummyStore extends Store<AppState> {
DummyStore() : super((state, dynamic action) => state, initialState: AppState.initialState());
}

class MockRemoteConfigRepository extends Mock implements RemoteConfigRepository {}

0 comments on commit def209b

Please sign in to comment.