diff --git a/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart b/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart index 429a1e1..e68535c 100644 --- a/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart +++ b/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart @@ -21,11 +21,13 @@ class AuthController extends Notifier { final config = ref.read(appConfigProvider); final configError = config.validationError; if (configError != null) { + if (!ref.mounted) return; state = state.copyWith(isLoading: false, errorMessage: configError); return; } if (config.useLocalAuth) { final localSession = await _localDataSource.readLocalSession(); + if (!ref.mounted) return; state = AuthState( isLoading: false, token: localSession != null ? const AuthToken(accessToken: 'local') : null, @@ -33,6 +35,7 @@ class AuthController extends Notifier { return; } final token = await _repository.getSavedToken(); + if (!ref.mounted) return; state = AuthState(isLoading: false, token: token); } diff --git a/front001/mosenioring/test/core/config/app_config_test.dart b/front001/mosenioring/test/core/config/app_config_test.dart new file mode 100644 index 0000000..68ae001 --- /dev/null +++ b/front001/mosenioring/test/core/config/app_config_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/core/config/app_config.dart'; + +void main() { + group('AppConfig', () { + test('validationError returns null when API_BASE_URL and useLocalAuth are set', () { + const config = AppConfig( + apiBaseUrl: 'http://localhost:8080', + useLocalAuth: true, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: '', + keycloakClientId: '', + keycloakRedirectUrl: '', + ); + + expect(config.validationError, isNull); + }); + + test('validationError returns error when API_BASE_URL is missing', () { + const config = AppConfig( + apiBaseUrl: ' ', + useLocalAuth: true, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: '', + keycloakClientId: '', + keycloakRedirectUrl: '', + ); + + expect(config.validationError, contains('Missing API_BASE_URL')); + }); + + test('validationError returns error when Keycloak config is missing and not using local auth', () { + // Assuming we are on a platform that supports AppAuth in tests (usually not, so it might hit the platform check first) + // Actually, in terminal tests, defaultTargetPlatform might be android or ios depending on environment, but often it's not. + // Let's test the logic regardless of platform by checking the specific requirements. + + const config = AppConfig( + apiBaseUrl: 'http://localhost:8080', + useLocalAuth: false, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: '', + keycloakClientId: '', + keycloakRedirectUrl: '', + ); + + final error = config.validationError; + expect(error, isNotNull); + // It will either fail on platform support or on missing issuer. + }); + + test('keycloakDiscoveryUrl is correctly formed', () { + const config = AppConfig( + apiBaseUrl: 'http://localhost:8080', + useLocalAuth: false, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: 'https://auth.example.com/realms/myrealm', + keycloakClientId: 'client', + keycloakRedirectUrl: 'app://callback', + ); + + expect(config.keycloakDiscoveryUrl, 'https://auth.example.com/realms/myrealm/.well-known/openid-configuration'); + }); + + test('allowInsecureConnections is true for http issuer', () { + const config = AppConfig( + apiBaseUrl: 'http://localhost:8080', + useLocalAuth: false, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: 'http://localhost:8081', + keycloakClientId: 'client', + keycloakRedirectUrl: 'app://callback', + ); + + expect(config.allowInsecureConnections, isTrue); + }); + + test('allowInsecureConnections is false for https issuer', () { + const config = AppConfig( + apiBaseUrl: 'http://localhost:8080', + useLocalAuth: false, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: 'https://auth.example.com', + keycloakClientId: 'client', + keycloakRedirectUrl: 'app://callback', + ); + + expect(config.allowInsecureConnections, isFalse); + }); + }); +} diff --git a/front001/mosenioring/test/features/auth/auth_controller_test.dart b/front001/mosenioring/test/features/auth/auth_controller_test.dart new file mode 100644 index 0000000..60265d5 --- /dev/null +++ b/front001/mosenioring/test/features/auth/auth_controller_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/di/providers.dart'; +import 'package:mosenioring/src/features/auth/domain/auth_repository.dart'; +import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart'; +import 'package:mosenioring/src/features/auth/data/auth_local_data_source.dart'; +import 'package:mosenioring/src/core/config/app_config.dart'; + +class MockAuthRepository implements AuthRepository { + AuthToken? token; + Object? loginError; + bool logoutCalled = false; + + @override + Future getSavedToken() async => token; + + @override + Future login({required String email, required String password}) async { + if (loginError != null) throw loginError!; + return const AuthToken(accessToken: 'new_token'); + } + + @override + Future logout() async { + logoutCalled = true; + } + + @override + Future persistToken(AuthToken token) async { + this.token = token; + } +} + +class MockAuthLocalDataSource implements AuthLocalDataSource { + LocalAuthSession? session; + AuthToken? token; + + @override + Future clear() async { + session = null; + token = null; + } + + @override + Future readLocalSession() async => session; + + @override + Future readToken() async => token; + + @override + Future saveLocalSession({required String email, required String roles, required String tenantId}) async { + session = LocalAuthSession(email: email, roles: roles, tenantId: tenantId); + } + + @override + Future saveToken(AuthToken token) async { + this.token = token; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('AuthController', () { + late MockAuthRepository mockRepository; + late AppConfig testConfig; + + setUp(() { + mockRepository = MockAuthRepository(); + testConfig = const AppConfig( + apiBaseUrl: 'https://api.example.com', + useLocalAuth: false, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: 'https://keycloak.com', + keycloakClientId: 'client', + keycloakRedirectUrl: 'app://callback', + ); + }); + + ProviderContainer createContainer({AppConfig? config}) { + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(mockRepository), + appConfigProvider.overrideWithValue(config ?? testConfig), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('initial state is loading then unauthenticated when no token', () async { + final container = createContainer(); + + // Wait for bootstrap microtask + await Future.microtask(() {}); + + final state = container.read(authControllerProvider); + expect(state.isLoading, isFalse); + expect(state.isAuthenticated, isFalse); + }); + + test('bootstrap loads saved token', () async { + mockRepository.token = const AuthToken(accessToken: 'saved_token'); + final container = createContainer(); + + // Keep checking until state is updated or timeout + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + if (container.read(authControllerProvider).isAuthenticated) break; + } + + final state = container.read(authControllerProvider); + expect(state.isAuthenticated, isTrue); + expect(state.token?.accessToken, 'saved_token'); + }); + + test('login success updates state', () async { + final container = createContainer(); + await Future.microtask(() {}); + + await container.read(authControllerProvider.notifier).login( + email: 'test@example.com', + password: 'password', + ); + + final state = container.read(authControllerProvider); + expect(state.isAuthenticated, isTrue); + expect(state.token?.accessToken, 'new_token'); + expect(state.errorMessage, isNull); + }); + + test('login failure updates error message', () async { + final container = createContainer(); + await Future.microtask(() {}); + mockRepository.loginError = Exception('Invalid credentials'); + + await container.read(authControllerProvider.notifier).login( + email: 'test@example.com', + password: 'password', + ); + + final state = container.read(authControllerProvider); + expect(state.isAuthenticated, isFalse); + expect(state.errorMessage, 'Invalid credentials'); + }); + + test('logout clears state', () async { + mockRepository.token = const AuthToken(accessToken: 'saved_token'); + final container = createContainer(); + await Future.microtask(() {}); + + await container.read(authControllerProvider.notifier).logout(); + + final state = container.read(authControllerProvider); + expect(state.isAuthenticated, isFalse); + expect(mockRepository.logoutCalled, isTrue); + }); + }); +}