diff --git a/src/mobile/test/app/auth/bloc/auth_bloc_test.dart b/src/mobile/test/app/auth/bloc/auth_bloc_test.dart new file mode 100644 index 0000000000..e25e140ff1 --- /dev/null +++ b/src/mobile/test/app/auth/bloc/auth_bloc_test.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:airqo/src/app/auth/bloc/auth_bloc.dart'; +import 'package:airqo/src/app/auth/models/input_model.dart'; +import 'package:airqo/src/app/auth/repository/auth_repository.dart'; +import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeAuthRepository extends AuthRepository { + @override + Future deleteUserAccount() async {} + + @override + Future loginWithEmailAndPassword( + String username, String password) async { + throw UnimplementedError(); + } + + @override + Future registerWithEmailAndPassword(RegisterInputModel model) async { + throw UnimplementedError(); + } + + @override + Future requestPasswordReset(String email) async { + throw UnimplementedError(); + } + + @override + Future updatePassword({ + required String token, + required String password, + required String confirmPassword, + }) async { + throw UnimplementedError(); + } + + @override + Future validateResetCodeFormat(String pin) async { + throw UnimplementedError(); + } + + @override + Future verifyEmailCode(String token, String email) async { + throw UnimplementedError(); + } +} + +class _FakeSocialAuthRepository extends SocialAuthRepository { + @override + Future loginWithProvider(String provider) async { + throw UnimplementedError(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + String expiredToken() { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': 'user-1', + 'exp': DateTime.now() + .subtract(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + })}.'; + } + + String validToken() { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': 'user-1', + 'exp': DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + })}.'; + } + + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + dotenv.testLoad(fileInput: ''' +FORCE_AUTH_TOKEN_EXPIRED=false +AUTH_DEBUG_SEED_MOBILE_TOKEN=false +'''); + }); + + tearDown(() { + ApiUtils.baseUrl = 'http://localhost:3001'; + }); + + group('AuthBloc on AppStarted', () { + // My current hypothesis is that the user-visible logout flow starts here + // once the helper has already reduced the session problem to "null". + // If app startup sees a stored token but cannot restore a usable session, + // the bloc should move through loading, mark the session as expired, and + // finally fall back to guest mode. + test('emits loading, session expired, then guest when startup cannot restore the session', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + ApiUtils.baseUrl = 'http://127.0.0.1:${server.port}'; + + server.listen((request) async { + expect(request.uri.path, '/api/v2/users/token/refresh'); + request.response.statusCode = HttpStatus.internalServerError; + request.response.write('{"success":false}'); + await request.response.close(); + }); + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + expiredToken(), + ); + + final bloc = AuthBloc( + authRepository: _FakeAuthRepository(), + socialAuthRepository: _FakeSocialAuthRepository(), + ); + + bloc.add(AppStarted()); + + await expectLater( + bloc.stream, + emitsInOrder([ + isA(), + isA(), + isA(), + ]), + ); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + expect(storedToken, isNull); + + await server.close(force: true); + await bloc.close(); + }); + + // The next thing we need is the healthy startup baseline. If the app finds + // a still-valid stored token, startup should restore the session instead + // of pushing the user into the expiry flow. This gives us the clean + // comparison against the failing startup case above. + test('emits loading then loaded when startup finds a valid stored session', + () async { + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + validToken(), + ); + + final bloc = AuthBloc( + authRepository: _FakeAuthRepository(), + socialAuthRepository: _FakeSocialAuthRepository(), + ); + + bloc.add(AppStarted()); + + await expectLater( + bloc.stream, + emitsInOrder([ + isA(), + isA().having( + (state) => state.authPurpose, + 'authPurpose', + AuthPurpose.login, + ), + ]), + ); + + await bloc.close(); + }); + + }); + + group('AuthBloc on SessionExpired', () { + // Once another layer has already decided the session is no longer usable, + // the auth bloc should perform the same cleanup and fallback sequence the + // user would feel in the app: mark the session as expired, then return to + // guest mode. + test('emits session expired then guest and clears stored auth data', + () async { + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + validToken(), + ); + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.userId, + 'user-1', + ); + + final bloc = AuthBloc( + authRepository: _FakeAuthRepository(), + socialAuthRepository: _FakeSocialAuthRepository(), + ); + + bloc.add(const SessionExpired()); + + await expectLater( + bloc.stream, + emitsInOrder([ + isA(), + isA(), + ]), + ); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final storedUserId = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.userId); + + expect(storedToken, isNull); + expect(storedUserId, isNull); + + await bloc.close(); + }); + }); + + group('AuthBloc on LogoutUser', () { + // This is the intentional version of leaving a session. We need it so we + // can compare "the user chose to log out" with "the app forced the user + // out because it believed the session had expired". + test('emits loading then guest and clears stored auth data', () async { + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + validToken(), + ); + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.userId, + 'user-1', + ); + + final bloc = AuthBloc( + authRepository: _FakeAuthRepository(), + socialAuthRepository: _FakeSocialAuthRepository(), + ); + + bloc.add(const LogoutUser()); + + await expectLater( + bloc.stream, + emitsInOrder([ + isA(), + isA(), + ]), + ); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final storedUserId = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.userId); + + expect(storedToken, isNull); + expect(storedUserId, isNull); + + await bloc.close(); + }); + }); +} diff --git a/src/mobile/test/app/auth/integration/session_expiry_flow_test.dart b/src/mobile/test/app/auth/integration/session_expiry_flow_test.dart new file mode 100644 index 0000000000..a3fa518efd --- /dev/null +++ b/src/mobile/test/app/auth/integration/session_expiry_flow_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/auth/bloc/auth_bloc.dart'; +import 'package:airqo/src/app/auth/models/input_model.dart'; +import 'package:airqo/src/app/auth/repository/auth_repository.dart'; +import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; +import 'package:airqo/src/app/shared/exceptions/session_expired_exception.dart'; +import 'package:airqo/src/app/shared/repository/base_repository.dart'; +import 'package:airqo/src/app/shared/repository/global_auth_manager.dart'; +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:airqo/src/app/shared/repository/token_refresher.dart'; +import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +class _FakeAuthRepository extends AuthRepository { + @override + Future deleteUserAccount() async {} + + @override + Future loginWithEmailAndPassword( + String username, String password) async { + throw UnimplementedError(); + } + + @override + Future registerWithEmailAndPassword(RegisterInputModel model) async { + throw UnimplementedError(); + } + + @override + Future requestPasswordReset(String email) async { + throw UnimplementedError(); + } + + @override + Future updatePassword({ + required String token, + required String password, + required String confirmPassword, + }) async { + throw UnimplementedError(); + } + + @override + Future validateResetCodeFormat(String pin) async { + throw UnimplementedError(); + } + + @override + Future verifyEmailCode(String token, String email) async { + throw UnimplementedError(); + } +} + +class _FakeSocialAuthRepository extends SocialAuthRepository { + @override + Future loginWithProvider(String provider) async { + throw UnimplementedError(); + } +} + +class _FakeTokenRefresher implements TokenRefresher { + const _FakeTokenRefresher(this.token); + + final String token; + + @override + Future refreshTokenIfNeeded() async => token; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + GlobalAuthManager.instance.resetSessionExpiredGuard(); + }); + + tearDown(() { + ApiUtils.baseUrl = 'http://localhost:3001'; + }); + + group('session expiry integration flow', () { + // My current hypothesis is that one of the real forced-logout paths is: + // repository GET receives a session-like 401, the global auth manager + // raises SessionExpired, and the auth bloc clears stored auth data before + // returning the app to guest mode. This test wires those layers together + // so we can verify that the same chain happens as one end-to-end flow. + test('session-like 401 from repository triggers bloc expiry cleanup and guest fallback', + () async { + final token = _jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + await SecureStorageRepository.instance + .saveSecureData(SecureStorageKeys.authToken, token); + await SecureStorageRepository.instance + .saveSecureData(SecureStorageKeys.userId, 'user-1'); + + final bloc = AuthBloc( + authRepository: _FakeAuthRepository(), + socialAuthRepository: _FakeSocialAuthRepository(), + ); + addTearDown(bloc.close); + GlobalAuthManager.instance.setAuthBloc(bloc); + + final repository = BaseRepository( + tokenRefresher: _FakeTokenRefresher(token), + ); + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/test'); + expect(request.headers['Authorization'], 'JWT $token'); + + return http.Response( + jsonEncode({'message': 'Token expired'}), + 401, + headers: {'content-type': 'application/json'}, + ); + }); + + ApiUtils.baseUrl = 'https://api.test'; + + final futureStates = expectLater( + bloc.stream, + emitsInOrder([ + isA(), + isA(), + ]), + ); + + await expectLater( + () => http.runWithClient( + () => repository.createGetRequest('/test', const {}), + () => client, + ), + throwsA(isA()), + ); + + await futureStates; + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final storedUserId = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.userId); + + expect(storedToken, isNull); + expect(storedUserId, isNull); + }); + }); +} + +String _jwtToken({ + required String userId, + required DateTime expiresAt, +}) { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': userId, + 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, + })}.'; +} diff --git a/src/mobile/test/app/auth/repository/base_repository_auth_test.dart b/src/mobile/test/app/auth/repository/base_repository_auth_test.dart new file mode 100644 index 0000000000..bd4110e417 --- /dev/null +++ b/src/mobile/test/app/auth/repository/base_repository_auth_test.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:airqo/src/app/shared/exceptions/session_expired_exception.dart'; +import 'package:airqo/src/app/shared/repository/base_repository.dart'; +import 'package:airqo/src/app/shared/repository/session_expiry_notifier.dart'; +import 'package:airqo/src/app/shared/repository/token_refresher.dart'; +import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +class _FakeTokenRefresher implements TokenRefresher { + const _FakeTokenRefresher(this.token); + + final String? token; + + @override + Future refreshTokenIfNeeded() async => token; +} + +class _RecordingSessionExpiryNotifier implements SessionExpiryNotifier { + int notifications = 0; + + @override + void notifySessionExpired() { + notifications += 1; + } +} + +void main() { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + tearDown(() { + ApiUtils.baseUrl = 'http://localhost:3001'; + }); + + group('BaseRepository authenticated response handling', () { + // My current hypothesis is that repository-level 401 handling is the other + // major path that can force the user out. For GET requests, the repository + // is trying to distinguish "the user's JWT is bad" from unrelated 401s. + // If the body clearly looks like a token/session problem, it should notify + // session expiry and throw the dedicated exception. + // + // If this test passes, we have confirmed that a session-like 401 body is + // intentionally treated as a forced logout signal at the repository layer. + test('notifies session expiry and throws when a GET request returns a session-like 401 body', + () async { + const token = 'valid-token'; + final notifier = _RecordingSessionExpiryNotifier(); + final repository = BaseRepository( + tokenRefresher: const _FakeTokenRefresher(token), + sessionExpiryNotifier: notifier, + ); + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/test'); + expect(request.headers['Authorization'], 'JWT $token'); + + return http.Response( + jsonEncode({'message': 'Token expired'}), + 401, + headers: {'content-type': 'application/json'}, + ); + }); + + ApiUtils.baseUrl = 'https://api.test'; + + await expectLater( + () => http.runWithClient( + () => repository.createGetRequest('/test', const {}), + () => client, + ), + throwsA(isA()), + ); + + expect(notifier.notifications, 1); + }); + + // The repository is also trying to avoid false logouts. Some GET requests + // can return 401 for reasons that are not really about the user's session, + // so a body that does not look JWT- or session-related should stay a + // normal request failure instead of escalating into a session-expired flow. + test('does not notify session expiry when a GET request returns an unrelated 401 body', + () async { + const token = 'valid-token'; + final notifier = _RecordingSessionExpiryNotifier(); + final repository = BaseRepository( + tokenRefresher: const _FakeTokenRefresher(token), + sessionExpiryNotifier: notifier, + ); + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/test'); + expect(request.headers['Authorization'], 'JWT $token'); + + return http.Response( + jsonEncode({'message': 'API key missing'}), + 401, + headers: {'content-type': 'application/json'}, + ); + }); + + ApiUtils.baseUrl = 'https://api.test'; + + await expectLater( + () => http.runWithClient( + () => repository.createGetRequest('/test', const {}), + () => client, + ), + throwsA( + predicate( + (error) => + error is Exception && + error.toString().contains('API key missing') && + error is! SessionExpiredException, + ), + ), + ); + + expect(notifier.notifications, 0); + }); + + // Successful authenticated responses can quietly deliver a replacement + // token in the response headers. The repository should persist that token + // so later requests keep using the refreshed session instead of falling + // back to stale credentials. + test('stores the refreshed token from a successful response header', + () async { + const oldToken = 'old-token'; + final refreshedToken = _jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + final notifier = _RecordingSessionExpiryNotifier(); + final repository = BaseRepository( + tokenRefresher: const _FakeTokenRefresher(oldToken), + sessionExpiryNotifier: notifier, + ); + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/test'); + expect(request.headers['Authorization'], 'JWT $oldToken'); + + return http.Response( + jsonEncode({'success': true}), + 200, + headers: { + 'content-type': 'application/json', + 'x-access-token': refreshedToken, + }, + ); + }); + + ApiUtils.baseUrl = 'https://api.test'; + + final response = await http.runWithClient( + () => repository.createAuthenticatedGetRequest('/test', const {}), + () => client, + ); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final storedUserId = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.userId); + + expect(response.statusCode, 200); + expect(storedToken, refreshedToken); + expect(storedUserId, 'user-1'); + expect(notifier.notifications, 0); + }); + }); +} + +String _jwtToken({ + required String userId, + required DateTime expiresAt, +}) { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': userId, + 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, + })}.'; +} diff --git a/src/mobile/test/app/auth/repository/secure_storage_repository_test.dart b/src/mobile/test/app/auth/repository/secure_storage_repository_test.dart new file mode 100644 index 0000000000..71f588f959 --- /dev/null +++ b/src/mobile/test/app/auth/repository/secure_storage_repository_test.dart @@ -0,0 +1,48 @@ +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + group('SecureStorageRepository', () { + // My current hypothesis is that auth can appear unstable if the storage + // layer does not give back exactly what was written. Before we blame token + // refresh or lifecycle logic, we need to prove the storage wrapper can do + // a calm round-trip for auth values. + // + // If this test passes, we have confirmed that the repository can save and + // later retrieve a stored auth token in the expected form. + test('saves and retrieves a secure value', () async { + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + 'stored-token-value', + ); + + final storedValue = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + + expect(storedValue, 'stored-token-value'); + }); + + // Session-expiry and logout paths both rely on secure values actually + // disappearing when the app asks for cleanup. If delete fails silently, + // later startup checks can look like random re-authentication bugs. + test('deletes a stored secure value', () async { + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + 'stored-token-value', + ); + + await SecureStorageRepository.instance + .deleteSecureData(SecureStorageKeys.authToken); + + final storedValue = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + + expect(storedValue, isNull); + }); + }); +} diff --git a/src/mobile/test/app/auth/repository/user_preferences_auth_test.dart b/src/mobile/test/app/auth/repository/user_preferences_auth_test.dart new file mode 100644 index 0000000000..7c7defb0db --- /dev/null +++ b/src/mobile/test/app/auth/repository/user_preferences_auth_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/auth/services/auth_token_storage.dart'; +import 'package:airqo/src/app/dashboard/repository/user_preferences_repository.dart'; +import 'package:airqo/src/app/shared/repository/hive_repository.dart'; +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + String jwtToken({ + required String userId, + required DateTime expiresAt, + }) { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': userId, + 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, + })}.'; + } + + setUpAll(() async { + Hive.init('.'); + }); + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + await HiveRepository.clearAllCache(); + dotenv.testLoad(fileInput: ''' +AIRQO_API_URL=https://api.test +AIRQO_MOBILE_TOKEN=JWT app-token +'''); + }); + + tearDown(() async { + ApiUtils.baseUrl = 'http://localhost:3001'; + await HiveRepository.clearAllCache(); + }); + + group('UserPreferencesImpl auth handling', () { + // My current hypothesis is that user-preferences fetch is one of the + // hidden places that can look like a session-expiry bug even when the + // core refresh path is healthy. This test gives us the healthy baseline: + // a user token is refreshed successfully, preferences load with 200, and + // the repository keeps the user authenticated while caching the result. + test('uses a refreshed token and stays healthy when preferences fetch succeeds', + () async { + final expiredToken = jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + final refreshedToken = jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + expiredToken, + ); + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.userId, + 'user-1', + ); + + final repository = UserPreferencesImpl(); + var refreshSeen = false; + + final refreshResult = await http.runWithClient( + () => repository.getUserPreferences('user-1'), + () => MockClient((request) async { + if (!refreshSeen && + request.method == 'POST' && + request.url.path == '/api/v2/users/token/refresh') { + refreshSeen = true; + expect(request.headers['Authorization'], 'JWT $expiredToken'); + + return http.Response( + jsonEncode({ + 'success': true, + 'message': 'Token refreshed successfully', + 'token': refreshedToken, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + + expect(request.method, 'GET'); + expect(request.url.path, '/api/v2/users/preferences/user-1'); + expect(request.headers['Authorization'], 'JWT $refreshedToken'); + + return http.Response( + jsonEncode({ + 'success': true, + 'preferences': {'theme': 'light'} + }), + 200, + headers: { + 'content-type': 'application/json', + 'x-access-token': refreshedToken, + }, + ); + }), + ); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final cachedPreferences = await HiveRepository.getCache( + 'user_preferences_user-1_', + ); + + expect(refreshResult['success'], true); + expect(storedToken, refreshedToken); + expect(cachedPreferences, isNotNull); + expect(refreshSeen, isTrue); + }); + + // We also need to capture the more dangerous branch. If preferences fetch + // returns 401 after refresh has already failed or fallen back, this + // repository explicitly flags the session as expired and still returns + // cached data when available. If this test passes, we have confirmed that + // preferences can independently force the logout flow even outside the + // main auth bloc startup path. + test('marks auth_error true and falls back to cache when preferences fetch returns 401', + () async { + final validToken = jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + validToken, + ); + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.userId, + 'user-1', + ); + await HiveRepository.saveCache( + 'user_preferences_user-1_', + { + 'success': true, + 'preferences': {'theme': 'cached-dark'} + }, + ); + + final repository = UserPreferencesImpl(); + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v2/users/preferences/user-1'); + expect(request.headers['Authorization'], 'JWT $validToken'); + + return http.Response( + jsonEncode({'message': 'Unauthorized'}), + 401, + headers: {'content-type': 'application/json'}, + ); + }); + + final result = await http.runWithClient( + () => repository.getUserPreferences('user-1'), + () => client, + ); + + expect(result['success'], true); + expect( + ((result['preferences'] as Map)['theme']), + 'cached-dark', + ); + }); + }); +} diff --git a/src/mobile/test/app/auth/services/auth_helper_test.dart b/src/mobile/test/app/auth/services/auth_helper_test.dart new file mode 100644 index 0000000000..22e44cd9d0 --- /dev/null +++ b/src/mobile/test/app/auth/services/auth_helper_test.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:airqo/src/app/auth/services/auth_helper.dart'; +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + String expiredToken() { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': 'user-1', + 'exp': DateTime.now() + .subtract(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + })}.'; + } + + String validToken() { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': 'user-1', + 'exp': DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + })}.'; + } + + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + dotenv.testLoad(fileInput: ''' +FORCE_AUTH_TOKEN_EXPIRED=false +AUTH_DEBUG_SEED_MOBILE_TOKEN=false +'''); + }); + + tearDown(() { + ApiUtils.baseUrl = 'http://localhost:3001'; + }); + + group('AuthHelper.refreshTokenIfNeeded', () { + // My current hypothesis is that unexpected logout begins here: + // when the app resumes with an expired stored token, it tries to refresh + // that token before restoring the session. If the refresh call fails, + // the helper returns null, and higher layers may interpret that as + // "the session has expired" and send the user back to the welcome screen. + // This test isolates that first signal so we can prove the behavior before + // checking how the bloc and lifecycle code react to it. + test('returns null when expired token refresh fails', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + ApiUtils.baseUrl = 'http://127.0.0.1:${server.port}'; + + server.listen((request) async { + expect(request.uri.path, '/api/v2/users/token/refresh'); + request.response.statusCode = HttpStatus.internalServerError; + request.response.write('{"success":false}'); + await request.response.close(); + }); + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + expiredToken(), + ); + + final token = await AuthHelper.refreshTokenIfNeeded(); + + expect(token, isNull); + + await server.close(force: true); + }); + + // My next hypothesis is that a temporary network stall can produce the + // same first signal as a hard refresh failure: the token is expired, the + // app asks the server for a replacement, the request takes too long, and + // the helper returns null. If that happens, higher layers may not be able + // to distinguish "the session is truly dead" from "refresh timed out on + // resume", which is exactly the kind of confusion we are investigating. + test('returns null when expired token refresh times out', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + ApiUtils.baseUrl = 'http://127.0.0.1:${server.port}'; + + server.listen((request) async { + expect(request.uri.path, '/api/v2/users/token/refresh'); + await Future.delayed(const Duration(seconds: 11)); + request.response.statusCode = HttpStatus.ok; + request.response.write('{"success":true,"token":"fresh-token"}'); + await request.response.close(); + }); + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + expiredToken(), + ); + + final token = await AuthHelper.refreshTokenIfNeeded(); + + expect(token, isNull); + + await server.close(force: true); + }); + + // Before we move up to bloc and lifecycle behavior, we need to confirm the + // normal path is calm: if the stored token is still valid, the helper + // should simply give that token back instead of trying to refresh it. + // This matters because it tells us whether the bug only begins once the + // token is already expired, or whether even a healthy stored session is at + // risk. + test('returns the existing token when it is still valid', () async { + final token = validToken(); + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + token, + ); + + final result = await AuthHelper.refreshTokenIfNeeded(); + + expect(result, token); + }); + + // Another part of my investigation is whether ordinary mobile network + // problems collapse into the exact same helper output as a real refresh + // failure. If an expired token cannot be refreshed because the network is + // unavailable, higher layers may still just see null and treat that as a + // dead session. + test('returns null when expired token refresh hits a network error', + () async { + ApiUtils.baseUrl = 'http://127.0.0.1:1'; + + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + expiredToken(), + ); + + final token = await AuthHelper.refreshTokenIfNeeded(); + + expect(token, isNull); + }); + + // We also need to know whether broken token data follows the same path as + // true expiry problems. If secure storage contains something that is not a + // valid JWT, the helper should fail in a stable way instead of crashing or + // producing ambiguous state. + test('returns null when the stored token is malformed', () async { + await SecureStorageRepository.instance.saveSecureData( + SecureStorageKeys.authToken, + 'not-a-jwt', + ); + + final token = await AuthHelper.refreshTokenIfNeeded(); + + expect(token, isNull); + }); + }); +} diff --git a/src/mobile/test/app/auth/services/auth_token_storage_test.dart b/src/mobile/test/app/auth/services/auth_token_storage_test.dart new file mode 100644 index 0000000000..1e05bdeed1 --- /dev/null +++ b/src/mobile/test/app/auth/services/auth_token_storage_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/auth/services/auth_token_storage.dart'; +import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + group('AuthTokenStorage.sanitizeToken', () { + // My current hypothesis is that auth persistence can look flaky if the app + // stores transport-style token prefixes like "Bearer" or "JWT" instead of + // the raw JWT itself. This test isolates the cleanup step first, so we can + // prove the storage helper normalizes those prefixed values before we move + // on to saving tokens and extracting user IDs. + // + // If this test passes, we have confirmed that prefixed token strings are + // reduced to the raw token value in a stable way. + test('removes auth scheme prefixes and surrounding whitespace', () { + final sanitized = AuthTokenStorage.sanitizeToken( + ' Bearer JWT fresh-token-value ', + ); + + expect(sanitized, 'fresh-token-value'); + }); + }); + + group('AuthTokenStorage.saveAuthToken', () { + // The next thing we need to know is whether normalized token strings are + // actually what land in secure storage. If this step writes the cleaned + // token value, later auth checks are at least reading a sane stored token + // rather than a transport-formatted one. + test('saves the sanitized token value to secure storage', () async { + await AuthTokenStorage.saveAuthToken(' JWT stored-token-value '); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + + expect(storedToken, 'stored-token-value'); + }); + + // A stored auth token is only part of the session picture. This helper is + // also responsible for pulling the user identity out of the JWT so later + // parts of the app can treat the restored session as a real logged-in + // user, not just an opaque token string. + test('extracts and stores the user ID when saving a valid auth token', + () async { + final token = _jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + await AuthTokenStorage.saveAuthToken(token); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final storedUserId = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.userId); + + expect(storedToken, token); + expect(storedUserId, 'user-1'); + }); + }); + + group('AuthTokenStorage.saveTokenFromHeaders', () { + // This is the bridge between repository success responses and persisted + // session state. If a refreshed token arrives in response headers, this + // helper should store it in the same cleaned, usable form as a directly + // saved auth token. + test('stores the refreshed token and user ID from the x-access-token header', + () async { + final token = _jwtToken( + userId: 'user-1', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + await AuthTokenStorage.saveTokenFromHeaders({ + 'x-access-token': ' Bearer $token ', + }); + + final storedToken = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.authToken); + final storedUserId = await SecureStorageRepository.instance + .getSecureData(SecureStorageKeys.userId); + + expect(storedToken, token); + expect(storedUserId, 'user-1'); + }); + }); +} + +String _jwtToken({ + required String userId, + required DateTime expiresAt, +}) { + String encode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + + return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ + 'sub': userId, + 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, + })}.'; +}