[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: auth recovery

+576 -29
+3
lib/src/core/auth/data/repositories/auth_repository.dart
··· 15 15 /// Gets the current user's handle 16 16 String? get handle; 17 17 18 + /// Gets the saved handle from the last persisted session, if available 19 + String? get lastKnownHandle; 20 + 18 21 /// Gets the current user's PDS endpoint 19 22 String? get pdsEndpoint; 20 23
+28 -25
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 86 86 String? get handle => _handle; 87 87 88 88 @override 89 + String? get lastKnownHandle => _handle ?? _snapshot?.pdsSessionCache?.handle; 90 + 91 + @override 89 92 String? get pdsEndpoint => _pdsEndpoint; 90 93 91 94 @override ··· 132 135 if (snapshot.aipGrant != null) { 133 136 final refreshed = await _refreshAuthState(); 134 137 if (!refreshed) { 135 - await _clearSessionState(preserveRegistration: true); 138 + _resetInMemorySession(); 136 139 } 137 140 return; 138 141 } 139 142 140 143 if (snapshot.pdsSessionCache != null) { 141 - await _clearSessionState( 142 - preserveRegistration: snapshot.aipClientRegistration != null, 143 - ); 144 + _resetInMemorySession(); 144 145 } 145 146 } 146 147 ··· 495 496 496 497 @override 497 498 Future<LoginResult> completeOAuth(String callbackUrl) async { 499 + AuthSnapshot? previousSnapshot; 498 500 try { 499 501 await initializationComplete; 502 + previousSnapshot = _snapshot; 500 503 501 504 final context = await _readPendingContext(); 502 505 if (context == null) { ··· 540 543 authGeneration: _authGeneration, 541 544 ); 542 545 if (!bootstrapped) { 543 - await _clearSessionState(preserveRegistration: true); 546 + _snapshot = previousSnapshot; 547 + await _saveSnapshot(); 548 + _resetInMemorySession(); 544 549 return LoginResult.failed( 545 550 'Failed to bootstrap a direct PDS session from AIP.', 546 551 ); ··· 549 554 return LoginResult.success(); 550 555 } catch (e, stackTrace) { 551 556 _logger.e('AIP OAuth callback failed', error: e, stackTrace: stackTrace); 552 - await _clearSessionState(preserveRegistration: true); 557 + _snapshot = previousSnapshot; 558 + await _saveSnapshot(); 559 + _resetInMemorySession(); 553 560 return LoginResult.failed(e.toString()); 554 561 } finally { 555 562 await _clearPendingContext(); ··· 605 612 httpClient: _httpClient, 606 613 ); 607 614 608 - try { 609 - await client.refreshCredentials(); 610 - final refreshed = client.credentials; 611 - if (!_isCurrentAuthGeneration(authGeneration)) { 612 - return null; 613 - } 615 + await client.refreshCredentials(); 616 + final refreshed = client.credentials; 617 + if (!_isCurrentAuthGeneration(authGeneration)) { 618 + return null; 619 + } 614 620 615 - _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 616 - aipGrant: AipGrant(credentialsJson: refreshed.toJson()), 617 - ); 618 - await _saveSnapshot(); 619 - return refreshed; 620 - } finally { 621 - client.close(); 622 - } 621 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 622 + aipGrant: AipGrant(credentialsJson: refreshed.toJson()), 623 + ); 624 + await _saveSnapshot(); 625 + return refreshed; 623 626 } 624 627 625 628 Future<AipAtprotocolSessionResponse> _fetchAtprotocolSession( ··· 743 746 final session = await _fetchSessionInfo(atProto); 744 747 if (session.did != did) { 745 748 _logger.w('Session DID mismatch. Expected $did but got ${session.did}'); 746 - await logout(); 749 + _resetInMemorySession(); 747 750 return false; 748 751 } 749 752 ··· 761 764 762 765 final refreshed = await _refreshAuthState(); 763 766 if (!refreshed || _atProto == null) { 764 - await logout(); 767 + _resetInMemorySession(); 765 768 return false; 766 769 } 767 770 ··· 772 775 'Session DID mismatch after AIP rebootstrap. ' 773 776 'Expected $did but got ${session.did}', 774 777 ); 775 - await logout(); 778 + _resetInMemorySession(); 776 779 return false; 777 780 } 778 781 ··· 787 790 error: refreshError, 788 791 stackTrace: refreshStackTrace, 789 792 ); 790 - await logout(); 793 + _resetInMemorySession(); 791 794 return false; 792 795 } 793 796 } ··· 799 802 800 803 final refreshed = await _refreshAuthState(); 801 804 if (!refreshed) { 802 - await _clearSessionState(preserveRegistration: true); 805 + _resetInMemorySession(); 803 806 } 804 807 805 808 return refreshed;
+36
lib/src/core/l10n/app_localizations.dart
··· 1000 1000 /// **'Enter your handle to continue with OAuth'** 1001 1001 String get messageEnterHandle; 1002 1002 1003 + /// Title for auth recovery page when a saved session could not be verified 1004 + /// 1005 + /// In en, this message translates to: 1006 + /// **'Sign in again'** 1007 + String get pageTitleSignInAgain; 1008 + 1009 + /// Explanation on auth recovery page after saved session verification fails 1010 + /// 1011 + /// In en, this message translates to: 1012 + /// **'We found your saved account, but your session could not be verified. You can sign in again with this handle or go back to get started.'** 1013 + String get messageSavedSessionRecovery; 1014 + 1015 + /// Button label for continuing OAuth with a prefilled handle 1016 + /// 1017 + /// In en, this message translates to: 1018 + /// **'Continue as {handle}'** 1019 + String buttonContinueAs(String handle); 1020 + 1021 + /// Button label to leave auth recovery and open the normal get started page 1022 + /// 1023 + /// In en, this message translates to: 1024 + /// **'Go to get started'** 1025 + String get buttonGoToGetStarted; 1026 + 1027 + /// Validation error when handle field is empty 1028 + /// 1029 + /// In en, this message translates to: 1030 + /// **'Enter your handle'** 1031 + String get errorEnterHandle; 1032 + 1033 + /// Error message when OAuth sign in fails 1034 + /// 1035 + /// In en, this message translates to: 1036 + /// **'Sign in failed: {details}'** 1037 + String errorSignInFailed(String details); 1038 + 1003 1039 /// Loading message when completing sign up 1004 1040 /// 1005 1041 /// In en, this message translates to:
+23
lib/src/core/l10n/app_localizations_en.dart
··· 488 488 String get messageEnterHandle => 'Enter your handle to continue with OAuth'; 489 489 490 490 @override 491 + String get pageTitleSignInAgain => 'Sign in again'; 492 + 493 + @override 494 + String get messageSavedSessionRecovery => 495 + 'We found your saved account, but your session could not be verified. You can sign in again with this handle or go back to get started.'; 496 + 497 + @override 498 + String buttonContinueAs(String handle) { 499 + return 'Continue as $handle'; 500 + } 501 + 502 + @override 503 + String get buttonGoToGetStarted => 'Go to get started'; 504 + 505 + @override 506 + String get errorEnterHandle => 'Enter your handle'; 507 + 508 + @override 509 + String errorSignInFailed(String details) { 510 + return 'Sign in failed: $details'; 511 + } 512 + 513 + @override 491 514 String get messageCompletingSignUp => 'Completing sign up...'; 492 515 493 516 @override
+40
lib/src/core/l10n/intl_en.arb
··· 797 797 "description": "Message to enter handle for OAuth" 798 798 }, 799 799 800 + "pageTitleSignInAgain": "Sign in again", 801 + "@pageTitleSignInAgain": { 802 + "description": "Title for auth recovery page when a saved session could not be verified" 803 + }, 804 + 805 + "messageSavedSessionRecovery": "We found your saved account, but your session could not be verified. You can sign in again with this handle or go back to get started.", 806 + "@messageSavedSessionRecovery": { 807 + "description": "Explanation on auth recovery page after saved session verification fails" 808 + }, 809 + 810 + "buttonContinueAs": "Continue as {handle}", 811 + "@buttonContinueAs": { 812 + "description": "Button label for continuing OAuth with a prefilled handle", 813 + "placeholders": { 814 + "handle": { 815 + "type": "String" 816 + } 817 + } 818 + }, 819 + 820 + "buttonGoToGetStarted": "Go to get started", 821 + "@buttonGoToGetStarted": { 822 + "description": "Button label to leave auth recovery and open the normal get started page" 823 + }, 824 + 825 + "errorEnterHandle": "Enter your handle", 826 + "@errorEnterHandle": { 827 + "description": "Validation error when handle field is empty" 828 + }, 829 + 830 + "errorSignInFailed": "Sign in failed: {details}", 831 + "@errorSignInFailed": { 832 + "description": "Error message when OAuth sign in fails", 833 + "placeholders": { 834 + "details": { 835 + "type": "String" 836 + } 837 + } 838 + }, 839 + 800 840 "messageCompletingSignUp": "Completing sign up...", 801 841 "@messageCompletingSignUp": { 802 842 "description": "Loading message when completing sign up"
+7 -3
lib/src/core/routing/app_router.dart
··· 30 30 31 31 try { 32 32 await authRepository.initializationComplete; 33 - final hadSavedSession = authRepository.did?.isNotEmpty ?? false; 33 + final savedHandle = authRepository.lastKnownHandle?.trim(); 34 + final hadSavedSession = savedHandle != null && savedHandle.isNotEmpty; 34 35 final isSessionValid = await authRepository.validateSession(); 35 36 36 37 if (!isSessionValid) { 37 38 _logger.i( 38 39 hadSavedSession 39 - ? 'Redirecting to login because the saved session is no longer valid' 40 + ? 'Redirecting to auth recovery because the saved session could not be verified' 40 41 : 'Redirecting to register because the user is not signed in', 41 42 ); 42 43 resolver.redirectUntil( 43 - hadSavedSession ? const LoginRoute() : const RegisterRoute(), 44 + hadSavedSession 45 + ? AuthRecoveryRoute(handle: savedHandle) 46 + : const RegisterRoute(), 44 47 ); 45 48 return; 46 49 } ··· 177 180 178 181 // Alternate starting routes 179 182 AutoRoute(page: EmptyRoute.page, path: '/empty'), 183 + AutoRoute(page: AuthRecoveryRoute.page, path: '/auth/recover'), 180 184 AutoRoute(page: LoginRoute.page, path: '/login'), 181 185 AutoRoute(page: RegisterRoute.page, path: '/register'), 182 186 AutoRoute(page: OnboardingRoute.page, path: '/onboarding/profile'),
+1
lib/src/core/routing/pages.dart
··· 1 1 export 'package:spark/src/core/pro_video_editor/ui/video_editor_grounded_page.dart'; 2 2 export 'package:spark/src/features/auth/ui/pages/auth_prompt_page.dart'; 3 + export 'package:spark/src/features/auth/ui/pages/auth_recovery_page.dart'; 3 4 export 'package:spark/src/features/auth/ui/pages/login_page.dart'; 4 5 export 'package:spark/src/features/auth/ui/pages/onboarding_page.dart'; 5 6 export 'package:spark/src/features/auth/ui/pages/register_page.dart';
+271
lib/src/features/auth/ui/pages/auth_recovery_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter/services.dart'; 5 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; 7 + import 'package:spark/src/core/design_system/components/atoms/buttons/long_button.dart'; 8 + import 'package:spark/src/core/design_system/tokens/typography.dart'; 9 + import 'package:spark/src/core/l10n/app_localizations.dart'; 10 + import 'package:spark/src/core/routing/app_router.dart'; 11 + import 'package:spark/src/features/auth/providers/auth_providers.dart'; 12 + import 'package:spark/src/features/auth/providers/onboarding_providers.dart'; 13 + import 'package:spark/src/features/settings/providers/settings_provider.dart'; 14 + 15 + @RoutePage() 16 + class AuthRecoveryPage extends ConsumerStatefulWidget { 17 + const AuthRecoveryPage({this.handle = '', super.key}); 18 + 19 + final String handle; 20 + 21 + @override 22 + ConsumerState<AuthRecoveryPage> createState() => _AuthRecoveryPageState(); 23 + } 24 + 25 + class _AuthRecoveryPageState extends ConsumerState<AuthRecoveryPage> { 26 + late final TextEditingController _handleController; 27 + final _formKey = GlobalKey<FormState>(); 28 + bool _isCompletingOAuth = false; 29 + 30 + @override 31 + void initState() { 32 + super.initState(); 33 + _handleController = TextEditingController(text: widget.handle); 34 + _handleController.addListener(_onHandleChanged); 35 + WidgetsBinding.instance.addPostFrameCallback((_) { 36 + TextInput.ensureInitialized(); 37 + }); 38 + } 39 + 40 + @override 41 + void dispose() { 42 + _handleController.removeListener(_onHandleChanged); 43 + _handleController.dispose(); 44 + super.dispose(); 45 + } 46 + 47 + void _onHandleChanged() { 48 + if (mounted) { 49 + setState(() {}); 50 + } 51 + } 52 + 53 + Future<void> _initiateOAuth() async { 54 + if (!(_formKey.currentState?.validate() ?? false)) { 55 + return; 56 + } 57 + 58 + final authNotifier = ref.read(authProvider.notifier); 59 + final handle = _handleController.text.trim(); 60 + 61 + try { 62 + final authUrl = await authNotifier.initiateOAuth(handle); 63 + 64 + if (!mounted) return; 65 + 66 + String callbackUrl; 67 + try { 68 + callbackUrl = await FlutterWebAuth2.authenticate( 69 + url: authUrl, 70 + callbackUrlScheme: 'sprk', 71 + ); 72 + } on PlatformException catch (e) { 73 + if (e.code == 'CANCELED') { 74 + if (!mounted) return; 75 + setState(() { 76 + _isCompletingOAuth = false; 77 + }); 78 + authNotifier.resetOAuthState(); 79 + return; 80 + } 81 + rethrow; 82 + } 83 + 84 + if (!mounted) return; 85 + 86 + setState(() { 87 + _isCompletingOAuth = true; 88 + }); 89 + 90 + final completeResult = await authNotifier.completeOAuth(callbackUrl); 91 + 92 + if (!mounted) return; 93 + 94 + if (completeResult.isSuccess) { 95 + final hasSparkProfile = await ref 96 + .read(onboardingRepositoryProvider) 97 + .hasSparkProfile(); 98 + 99 + if (!mounted) return; 100 + 101 + if (hasSparkProfile) { 102 + await ref.read(settingsProvider.notifier).syncPreferencesFromServer(); 103 + 104 + if (!mounted) return; 105 + 106 + context.router.replaceAll([const MainRoute()]); 107 + } else { 108 + context.router.replaceAll([const OnboardingRoute()]); 109 + } 110 + } else { 111 + setState(() { 112 + _isCompletingOAuth = false; 113 + }); 114 + } 115 + } catch (e) { 116 + if (!mounted) return; 117 + setState(() { 118 + _isCompletingOAuth = false; 119 + }); 120 + final errorMessage = e is PlatformException 121 + ? AppLocalizations.of(context).errorSignInFailed(e.message ?? e.code) 122 + : AppLocalizations.of(context).errorSignInFailed(e.toString()); 123 + authNotifier.resetOAuthState(error: errorMessage); 124 + } 125 + } 126 + 127 + void _goToGetStarted() { 128 + ref.read(authProvider.notifier).resetOAuthState(); 129 + context.router.replaceAll([const RegisterRoute()]); 130 + } 131 + 132 + @override 133 + Widget build(BuildContext context) { 134 + final l10n = AppLocalizations.of(context); 135 + final isLoading = ref.watch( 136 + authProvider.select((state) => state.isLoading), 137 + ); 138 + final error = ref.watch(authProvider.select((state) => state.error)); 139 + final theme = Theme.of(context); 140 + final colorScheme = theme.colorScheme; 141 + 142 + return Scaffold( 143 + backgroundColor: colorScheme.surface, 144 + body: SafeArea( 145 + child: Center( 146 + child: SingleChildScrollView( 147 + padding: const EdgeInsets.all(24), 148 + child: Form( 149 + key: _formKey, 150 + child: Column( 151 + crossAxisAlignment: CrossAxisAlignment.stretch, 152 + children: [ 153 + if (_isCompletingOAuth) ...[ 154 + const Center(child: CircularProgressIndicator()), 155 + const SizedBox(height: 16), 156 + Text( 157 + l10n.errorCompletingSignIn, 158 + style: AppTypography.textMediumMedium.copyWith( 159 + color: colorScheme.onSurfaceVariant, 160 + ), 161 + textAlign: TextAlign.center, 162 + ), 163 + ] else ...[ 164 + Icon( 165 + FluentIcons.key_24_regular, 166 + size: 48, 167 + color: colorScheme.primary, 168 + ), 169 + const SizedBox(height: 24), 170 + Text( 171 + l10n.pageTitleSignInAgain, 172 + style: AppTypography.displaySmallBold.copyWith( 173 + color: colorScheme.onSurface, 174 + ), 175 + textAlign: TextAlign.center, 176 + ), 177 + const SizedBox(height: 12), 178 + Text( 179 + l10n.messageSavedSessionRecovery, 180 + style: AppTypography.textMediumMedium.copyWith( 181 + color: colorScheme.onSurfaceVariant, 182 + height: 1.5, 183 + ), 184 + textAlign: TextAlign.center, 185 + ), 186 + const SizedBox(height: 32), 187 + TextFormField( 188 + controller: _handleController, 189 + enabled: !isLoading, 190 + decoration: InputDecoration( 191 + prefixIcon: Icon( 192 + FluentIcons.person_24_regular, 193 + color: colorScheme.primary, 194 + ), 195 + filled: true, 196 + fillColor: colorScheme.surface, 197 + contentPadding: const EdgeInsets.symmetric( 198 + horizontal: 16, 199 + vertical: 12, 200 + ), 201 + enabledBorder: OutlineInputBorder( 202 + borderRadius: BorderRadius.circular(8), 203 + borderSide: BorderSide(color: colorScheme.outline), 204 + ), 205 + focusedBorder: OutlineInputBorder( 206 + borderRadius: BorderRadius.circular(8), 207 + borderSide: BorderSide(color: colorScheme.primary), 208 + ), 209 + errorBorder: OutlineInputBorder( 210 + borderRadius: BorderRadius.circular(8), 211 + borderSide: BorderSide(color: colorScheme.error), 212 + ), 213 + focusedErrorBorder: OutlineInputBorder( 214 + borderRadius: BorderRadius.circular(8), 215 + borderSide: BorderSide(color: colorScheme.error), 216 + ), 217 + ), 218 + style: AppTypography.textMediumMedium.copyWith( 219 + color: colorScheme.onSurface, 220 + ), 221 + textInputAction: TextInputAction.done, 222 + keyboardType: TextInputType.emailAddress, 223 + autofillHints: const [ 224 + AutofillHints.username, 225 + AutofillHints.email, 226 + ], 227 + validator: (value) { 228 + final handle = value?.trim() ?? ''; 229 + if (handle.isEmpty) { 230 + return l10n.errorEnterHandle; 231 + } 232 + return null; 233 + }, 234 + onEditingComplete: _initiateOAuth, 235 + ), 236 + if (error != null) ...[ 237 + const SizedBox(height: 16), 238 + Text( 239 + error, 240 + style: AppTypography.textSmallMedium.copyWith( 241 + color: colorScheme.error, 242 + ), 243 + textAlign: TextAlign.center, 244 + ), 245 + ], 246 + const SizedBox(height: 24), 247 + Opacity( 248 + opacity: isLoading ? 0.5 : 1.0, 249 + child: LongButton( 250 + label: l10n.buttonContinueAs( 251 + _handleController.text.trim(), 252 + ), 253 + onPressed: isLoading ? null : _initiateOAuth, 254 + ), 255 + ), 256 + const SizedBox(height: 12), 257 + LongButton( 258 + label: l10n.buttonGoToGetStarted, 259 + variant: LongButtonVariant.regular, 260 + onPressed: isLoading ? null : _goToGetStarted, 261 + ), 262 + ], 263 + ], 264 + ), 265 + ), 266 + ), 267 + ), 268 + ), 269 + ); 270 + } 271 + }
+164 -1
test/src/core/auth/data/repositories/auth_repository_impl_test.dart
··· 139 139 expect(sessionCalls, 1); 140 140 }); 141 141 142 + test('startup refresh failure preserves the saved auth snapshot', () async { 143 + final storage = _InMemoryStorage(); 144 + await _storeSnapshot( 145 + storage, 146 + AuthSnapshot( 147 + aipClientRegistration: const AipClientRegistration( 148 + clientId: 'client-1', 149 + ), 150 + aipGrant: AipGrant( 151 + credentialsJson: oauth2.Credentials( 152 + 'aip-access', 153 + expiration: DateTime.utc(2030, 1, 1), 154 + ).toJson(), 155 + ), 156 + pdsSessionCache: _pdsSessionCache( 157 + accessToken: _pdsJwt( 158 + clientId: 'client-1', 159 + exp: DateTime.utc(2020, 1, 1), 160 + ), 161 + expiresAt: DateTime.utc(2020, 1, 1), 162 + ), 163 + ), 164 + ); 165 + final storedBefore = await storage.getString(StorageKeys.account); 166 + 167 + final repository = AuthRepositoryImpl( 168 + secureStorage: storage, 169 + httpClient: MockClient( 170 + (_) async => http.Response('temporary outage', 503), 171 + ), 172 + logger: SparkLogger(name: 'AuthRepositoryTest'), 173 + ); 174 + 175 + await repository.initializationComplete; 176 + 177 + expect(repository.isAuthenticated, isFalse); 178 + expect(repository.handle, isNull); 179 + expect(repository.lastKnownHandle, 'test.sprk.so'); 180 + expect(await storage.getString(StorageKeys.account), storedBefore); 181 + }); 182 + 142 183 test( 143 184 'refreshToken refreshes AIP grant before fetching a new PDS session', 144 185 () async { ··· 167 208 168 209 var tokenCalls = 0; 169 210 var sessionCalls = 0; 170 - final client = MockClient((request) async { 211 + final client = _CloseAwareMockClient((request) async { 171 212 if (request.url.path == '/oauth/token') { 172 213 tokenCalls += 1; 173 214 return http.Response( ··· 204 245 final refreshed = await repository.refreshToken(); 205 246 206 247 expect(refreshed, isTrue); 248 + expect(client.isClosed, isFalse); 207 249 expect(tokenCalls, 1); 208 250 expect(sessionCalls, 1); 209 251 ··· 218 260 }, 219 261 ); 220 262 263 + test('refreshToken failure preserves the saved auth snapshot', () async { 264 + final storage = _InMemoryStorage(); 265 + await _storeSnapshot( 266 + storage, 267 + AuthSnapshot( 268 + aipClientRegistration: const AipClientRegistration( 269 + clientId: 'client-1', 270 + clientSecret: 'secret-1', 271 + ), 272 + aipGrant: AipGrant( 273 + credentialsJson: oauth2.Credentials( 274 + 'expired-aip-access', 275 + refreshToken: 'refresh-1', 276 + tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), 277 + expiration: DateTime.utc(2020, 1, 1), 278 + ).toJson(), 279 + ), 280 + pdsSessionCache: _pdsSessionCache( 281 + accessToken: _pdsJwt(clientId: 'client-1'), 282 + expiresAt: DateTime.utc(2030, 1, 1), 283 + ), 284 + ), 285 + ); 286 + final storedBefore = AuthSnapshot.fromJsonString( 287 + (await storage.getString(StorageKeys.account))!, 288 + ); 289 + 290 + final repository = AuthRepositoryImpl( 291 + secureStorage: storage, 292 + httpClient: MockClient( 293 + (_) async => http.Response('temporary outage', 503), 294 + ), 295 + logger: SparkLogger(name: 'AuthRepositoryTest'), 296 + ); 297 + 298 + await repository.initializationComplete; 299 + final refreshed = await repository.refreshToken(); 300 + 301 + expect(refreshed, isFalse); 302 + expect(repository.isAuthenticated, isFalse); 303 + final savedSnapshot = AuthSnapshot.fromJsonString( 304 + (await storage.getString(StorageKeys.account))!, 305 + ); 306 + expect(savedSnapshot.aipClientRegistration?.clientId, 'client-1'); 307 + expect(savedSnapshot.aipGrant?.credentialsJson, contains('refresh-1')); 308 + expect( 309 + savedSnapshot.pdsSessionCache?.did, 310 + storedBefore.pdsSessionCache?.did, 311 + ); 312 + }); 313 + 221 314 test('concurrent refreshToken calls share one in-flight refresh', () async { 222 315 final storage = _InMemoryStorage(); 223 316 await _storeSnapshot( ··· 444 537 expect(fetchCalls, 2); 445 538 expect(sessionCalls, 1); 446 539 expect(repository.handle, 'updated.sprk.so'); 540 + }); 541 + 542 + test('validateSession failure preserves the saved auth snapshot', () async { 543 + final storage = _InMemoryStorage(); 544 + await _storeSnapshot( 545 + storage, 546 + AuthSnapshot( 547 + aipClientRegistration: const AipClientRegistration( 548 + clientId: 'client-1', 549 + ), 550 + aipGrant: AipGrant( 551 + credentialsJson: oauth2.Credentials( 552 + 'aip-access', 553 + expiration: DateTime.utc(2030, 1, 1), 554 + ).toJson(), 555 + ), 556 + pdsSessionCache: _pdsSessionCache( 557 + accessToken: _pdsJwt(clientId: 'client-1'), 558 + expiresAt: DateTime.utc(2030, 1, 1), 559 + ), 560 + ), 561 + ); 562 + final storedBefore = AuthSnapshot.fromJsonString( 563 + (await storage.getString(StorageKeys.account))!, 564 + ); 565 + 566 + final repository = AuthRepositoryImpl( 567 + secureStorage: storage, 568 + httpClient: MockClient( 569 + (_) async => http.Response('temporary outage', 503), 570 + ), 571 + logger: SparkLogger(name: 'AuthRepositoryTest'), 572 + fetchSessionInfo: (_) async => throw Exception('Unauthorized'), 573 + ); 574 + 575 + await repository.initializationComplete; 576 + final isValid = await repository.validateSession(); 577 + 578 + expect(isValid, isFalse); 579 + expect(repository.isAuthenticated, isFalse); 580 + final savedSnapshot = AuthSnapshot.fromJsonString( 581 + (await storage.getString(StorageKeys.account))!, 582 + ); 583 + expect(savedSnapshot.aipClientRegistration?.clientId, 'client-1'); 584 + expect(savedSnapshot.aipGrant?.credentialsJson, contains('aip-access')); 585 + expect( 586 + savedSnapshot.pdsSessionCache?.did, 587 + storedBefore.pdsSessionCache?.did, 588 + ); 447 589 }); 448 590 449 591 test( ··· 823 965 _values[key] = List<String>.from(value); 824 966 } 825 967 } 968 + 969 + class _CloseAwareMockClient extends MockClient { 970 + _CloseAwareMockClient(super.fn); 971 + 972 + bool isClosed = false; 973 + 974 + @override 975 + Future<http.StreamedResponse> send(http.BaseRequest request) { 976 + if (isClosed) { 977 + throw StateError('HTTP client is closed'); 978 + } 979 + 980 + return super.send(request); 981 + } 982 + 983 + @override 984 + void close() { 985 + isClosed = true; 986 + super.close(); 987 + } 988 + }
+3
test/src/features/messages/providers/conversation_provider_test.dart
··· 202 202 String? get handle => 'me.test'; 203 203 204 204 @override 205 + String? get lastKnownHandle => handle; 206 + 207 + @override 205 208 Future<void> get initializationComplete async {} 206 209 207 210 @override