From 6de74fa64c4102bc17868824a61fdb8ac853cd6f Mon Sep 17 00:00:00 2001 From: Owais Date: Sun, 10 May 2026 16:30:02 -0500 Subject: [PATCH] fix: address refresh races (#43) * fix: token refresh race * refactor: simplify AuthRepository methods * docs: add pr template * chore: update CHANGELOG * fix: refresh persistence metadata * fix: input encoding * build: make logging quieter * fix: normalize map inputs * fix: update descriptor parameter and input coercion in Bluesky adapter * refactor: variable and message hygiene --- .github/pull_request_template.md | 23 + CHANGELOG.md | 8 +- lib/core/database/app_database.dart | 35 ++ lib/core/logging/logging_bloc_observer.dart | 6 +- lib/core/network/poptart_client_adapter.dart | 45 +- lib/features/auth/data/auth_repository.dart | 373 ++++++----- .../feed/cubit/feed_preferences_cubit.dart | 31 +- .../presentation/feed_management_screen.dart | 8 + test/core/database/app_database_test.dart | 42 ++ .../network/poptart_client_adapter_test.dart | 284 +++++++++ .../auth/data/auth_repository_test.dart | 581 +++++++++++++++--- .../cubit/feed_preferences_cubit_test.dart | 51 +- 12 files changed, 1228 insertions(+), 259 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 test/core/network/poptart_client_adapter_test.dart diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..372965f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Summary + + + +## Changes + +- *list the changes that were made, and link to any relevant issues or PRs.* + *If there are a lot of changes, consider grouping them by category.* + +## Testing + +- [ ] `flutter analyze` +- [ ] `flutter test --reporter=failures-only` + +## Risk + + + +## Checklist + +- [ ] I reviewed the diff for unrelated changes. +- [ ] I added or updated tests for touched behavior. +- [ ] I updated docs and/or the project CHANGELOG where needed. diff --git a/CHANGELOG.md b/CHANGELOG.md index d34489a..8dca1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # CHANGELOG -## v1.0.0 (Alpha 6 - unreleased) +## v1.0.0 (Alpha 7) + +### Fixed + +- Token refresh race condition + +## v1.0.0 (Alpha 6) ### Added diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index a0cb5e9..7ef78a3 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -246,6 +246,41 @@ class AppDatabase extends _$AppDatabase { return rowsAffected > 0; } + Future updateAccountSessionIfRefreshTokenMatches( + String did, { + required String expectedRefreshToken, + required String handle, + required String accessToken, + required String refreshToken, + DateTime? expiresAt, + String? displayName, + String? service, + String? oauthService, + String? oauthClientId, + String? dpopNonce, + String? dpopPublicKey, + String? dpopPrivateKey, + }) async { + final query = update(accounts)..where((a) => a.did.equals(did) & a.refreshToken.equals(expectedRefreshToken)); + final rowsAffected = await query.write( + AccountsCompanion( + handle: Value(handle), + displayName: displayName != null ? Value(displayName) : const Value.absent(), + service: service != null ? Value(service) : const Value.absent(), + oauthService: oauthService != null ? Value(oauthService) : const Value.absent(), + oauthClientId: oauthClientId != null ? Value(oauthClientId) : const Value.absent(), + accessToken: Value(accessToken), + refreshToken: Value(refreshToken), + dpopNonce: dpopNonce != null ? Value(dpopNonce) : const Value.absent(), + dpopPublicKey: dpopPublicKey != null ? Value(dpopPublicKey) : const Value.absent(), + dpopPrivateKey: dpopPrivateKey != null ? Value(dpopPrivateKey) : const Value.absent(), + expiresAt: expiresAt != null ? Value(expiresAt) : const Value.absent(), + updatedAt: Value(DateTime.now()), + ), + ); + return rowsAffected > 0; + } + Future cacheProfile({ required String did, required String handle, diff --git a/lib/core/logging/logging_bloc_observer.dart b/lib/core/logging/logging_bloc_observer.dart index 07ce9c3..2431ec2 100644 --- a/lib/core/logging/logging_bloc_observer.dart +++ b/lib/core/logging/logging_bloc_observer.dart @@ -5,14 +5,14 @@ class LoggingBlocObserver extends BlocObserver { @override void onCreate(BlocBase bloc) { super.onCreate(bloc); - log.d('${bloc.runtimeType}: Created'); + log.t('${bloc.runtimeType}: Created'); } @override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); if (bloc is Bloc) { - log.d('${bloc.runtimeType}: Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); + log.t('${bloc.runtimeType}: Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); } } @@ -31,6 +31,6 @@ class LoggingBlocObserver extends BlocObserver { @override void onClose(BlocBase bloc) { super.onClose(bloc); - log.d('${bloc.runtimeType}: Closed'); + log.t('${bloc.runtimeType}: Closed'); } } diff --git a/lib/core/network/poptart_client_adapter.dart b/lib/core/network/poptart_client_adapter.dart index 907a11c..f1b059f 100644 --- a/lib/core/network/poptart_client_adapter.dart +++ b/lib/core/network/poptart_client_adapter.dart @@ -126,7 +126,14 @@ class Bluesky { P? parameters, I? input, }) { - return _client.call(method, service: service, headers: headers, parameters: parameters, input: input); + final descriptor = method.methodDescriptor; + return _client.call( + method, + service: service, + headers: headers, + parameters: _coerceDescriptorParameters(descriptor, parameters) as P?, + input: _coerceDescriptorInput(descriptor, input) as I?, + ); } Future> get( @@ -508,15 +515,47 @@ Future> _invokeDescriptor( dynamic input, }) { final normalized = _normalizeJson(values ?? const {}) as Map; - final dynamic parameters = descriptor.isQuery ? descriptor.parametersFromJson?.call(normalized) ?? normalized : null; + final dynamic parameters = descriptor.isQuery ? _coerceDescriptorParameters(descriptor, normalized) : null; final dynamic body = - input ?? + _coerceDescriptorInput(descriptor, input) ?? (descriptor.isProcedure ? descriptor.inputFromJson?.call(normalized) ?? (normalized.isEmpty ? null : normalized) : null); return client.call(descriptor, service: service, headers: headers, parameters: parameters, input: body); } +dynamic _coerceDescriptorParameters(XRPCMethodDescriptor descriptor, dynamic parameters) { + if (parameters == null) { + return null; + } + if (parameters is! Map) { + return parameters; + } + + final normalized = _normalizeJson(parameters) as Map; + final converter = descriptor.parametersFromJson; + if (converter != null) { + return converter.call(normalized); + } + return normalized.isEmpty ? null : normalized; +} + +dynamic _coerceDescriptorInput(XRPCMethodDescriptor descriptor, dynamic input) { + if (input == null) { + return null; + } + if (input is! Map) { + return input; + } + + final normalized = _normalizeJson(input) as Map; + final converter = descriptor.inputFromJson; + if (converter != null) { + return converter.call(normalized); + } + return normalized.isEmpty ? null : normalized; +} + XRPCMethodDescriptor? _descriptorFor(String nsid) { return switch (nsid) { 'app.bsky.actor.getPreferences' => actor_methods.appBskyActorGetPreferences, diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 2f18c3a..02cc4b8 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -2,9 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:lazurite/core/network/poptart_client_adapter.dart' as atp; -import 'package:poptart_core/poptart_core.dart' as atcore; -import 'package:poptart_oauth/poptart_oauth.dart'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -12,11 +9,14 @@ import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart' as atp; import 'package:lazurite/core/network/slingshot_client.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/features/auth/data/atproto_identifier.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; +import 'package:poptart_oauth/poptart_oauth.dart'; import 'package:url_launcher/url_launcher.dart'; typedef LaunchUrlWithMode = Future Function(Uri url, LaunchMode mode); @@ -119,6 +119,7 @@ class AuthRepository { OAuthClient? _pendingOAuthClient; OAuthContext? _pendingOAuthContext; Future? _pendingOAuthCallbackExchange; + final Map> _sessionRefreshesByDid = >{}; String? _pendingHandle; String? _pendingService; LaunchMode? _oauthLaunchMode; @@ -129,25 +130,7 @@ class AuthRepository { return null; } - final authMethod = account.dpopPrivateKey != null && account.dpopPublicKey != null - ? AuthMethod.oauth - : AuthMethod.appPassword; - - return AuthTokens( - accessToken: account.accessToken, - refreshToken: account.refreshToken, - expiresAt: account.expiresAt, - did: account.did, - handle: account.handle, - displayName: account.displayName, - service: account.service, - oauthService: authMethod == AuthMethod.oauth ? normalizeAtprotoServiceHost(account.oauthService) : null, - oauthClientId: authMethod == AuthMethod.oauth ? account.oauthClientId : null, - dpopNonce: account.dpopNonce, - dpopPublicKey: account.dpopPublicKey, - dpopPrivateKey: account.dpopPrivateKey, - authMethod: authMethod, - ); + return _tokensFromAccount(account); } Future restoreSession() async { @@ -220,13 +203,13 @@ class AuthRepository { _pendingHandle = normalizeAtProtoIdentifierForAuth(handle); final validationError = validateAtProtoIdentifierForAuth(_pendingHandle!); if (validationError != null) { - throw AuthIdentifierResolutionException(_identifierValidationMessage(validationError)); + throw AuthIdentifierResolutionException(validationError.code.message); } final preferredOauthService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; late final String resolvedPdsHost; String? resolvedAuthService; try { - resolvedPdsHost = await _resolveServiceForIdentifier(_pendingHandle!); + resolvedPdsHost = await resolveServiceForIdentifier(_pendingHandle!); } on atcore.InvalidRequestException catch (error, stackTrace) { final failure = _handleResolutionFailureForIdentifier(_pendingHandle!, error); log.w( @@ -247,25 +230,29 @@ class AuthRepository { stackTrace: stackTrace, ); } - final oauthServices = _oauthAuthorizeServiceCandidates( + final oauthServices = oauthAuthorizeServiceCandidates( preferredAuthService: preferredOauthService, resolvedPdsHost: resolvedPdsHost, resolvedAuthService: resolvedAuthService, ); + log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); log.d('AuthRepository: OAuth auth service candidates: ${oauthServices.join(', ')}'); final metadata = await _loadClientMetadata(kClientId); + log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); + final isAndroidNative = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; final isIosNative = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; - final redirectUri = _selectOAuthRedirectUriTemplate( + final redirectUri = selectOAuthRedirectUriTemplate( metadata.redirectUris, isAndroid: isAndroidNative, httpsAndroidCallbackEnabled: _androidHttpsCallbackEnabled, isIos: isIosNative, httpsIosCallbackEnabled: _iosHttpsCallbackEnabled, ); + log.d( 'AuthRepository: OAuth callback strategy ' 'androidNative=$isAndroidNative ' @@ -335,7 +322,7 @@ class AuthRepository { Future loginWithAppPassword(String handle, String appPassword) async { try { log.i('AuthRepository: Starting app password login for ${handle.trim()}'); - final service = await _resolveServiceForIdentifier(handle); + final service = await resolveServiceForIdentifier(handle); log.d('AuthRepository: Resolved app password login service to $service'); final session = await atp.createSession(identifier: handle, password: appPassword, service: service); @@ -364,26 +351,62 @@ class AuthRepository { throw Exception('No refresh token available for session refresh'); } - if (currentSession.usesOAuth) { - log.i('AuthRepository: Refreshing OAuth session for ${currentSession.handle}'); - final publicKey = currentSession.dpopPublicKey; - final privateKey = currentSession.dpopPrivateKey; + final existingRefresh = _sessionRefreshesByDid[currentSession.did]; + if (existingRefresh != null) { + log.d('AuthRepository: Joining in-flight session refresh for ${currentSession.handle}'); + return existingRefresh; + } + + late final Future refresh; + refresh = _refreshSession(currentSession).whenComplete(() { + if (identical(_sessionRefreshesByDid[currentSession.did], refresh)) { + _sessionRefreshesByDid.remove(currentSession.did); + } + }); + _sessionRefreshesByDid[currentSession.did] = refresh; + return refresh; + } + + Future _refreshSession(AuthTokens currentSession) async { + var session = currentSession; + final storedReplacement = await _storedSessionIfRefreshTokenChanged(currentSession); + if (storedReplacement != null) { + if (!storedReplacement.isExpired) { + log.i( + 'AuthRepository: Using newer stored session for ${storedReplacement.handle}; ' + 'requested refresh token is stale.', + ); + return storedReplacement; + } + log.i( + 'AuthRepository: Refreshing newer stored session for ${storedReplacement.handle}; ' + 'requested refresh token is stale.', + ); + session = storedReplacement; + } + + if (session.usesOAuth) { + log.i('AuthRepository: Refreshing OAuth session for ${session.handle}'); + final publicKey = session.dpopPublicKey; + final privateKey = session.dpopPrivateKey; if (publicKey == null || privateKey == null) { throw Exception('Stored OAuth session is missing DPoP keys'); } try { - final metadataClientId = _resolveOauthClientId(currentSession.oauthClientId); + final metadataClientId = _resolveOauthClientId(session.oauthClientId); final metadata = await _loadClientMetadata(metadataClientId); - final restoredSession = _restoreOAuthSession( - currentSession: currentSession, + final restoredSession = atcore.restoreOAuthSession( + accessToken: session.accessToken, + refreshToken: session.refreshToken!, + dPoPNonce: session.dpopNonce, publicKey: publicKey, privateKey: privateKey, ); final issuerHost = normalizeAtprotoServiceHost(restoredSession.accessTokenJwt.iss); - final storedAuthHost = normalizeAtprotoServiceHost(currentSession.oauthService); - final oauthServices = _oauthRefreshServiceCandidates( - storedAuthService: currentSession.oauthService, + final storedAuthHost = normalizeAtprotoServiceHost(session.oauthService); + final oauthServices = oauthRefreshServiceCandidates( + storedAuthService: session.oauthService, issuer: issuerHost, ); @@ -399,8 +422,10 @@ class AuthRepository { refreshedSession = await _oauthRefreshSession( metadata: metadata, service: oauthService, - session: _restoreOAuthSession( - currentSession: currentSession, + session: atcore.restoreOAuthSession( + accessToken: session.accessToken, + refreshToken: session.refreshToken!, + dPoPNonce: session.dpopNonce, publicKey: publicKey, privateKey: privateKey, ), @@ -442,24 +467,24 @@ class AuthRepository { ); } - final fallbackPdsHost = normalizeAtprotoServiceHost(currentSession.service) ?? _fallbackService; + final fallbackPdsHost = normalizeAtprotoServiceHost(session.service) ?? _fallbackService; final refreshedTokens = await _buildOAuthTokens( refreshedSession, - fallbackHandle: currentSession.handle, + fallbackHandle: session.handle, fallbackPdsHost: fallbackPdsHost, - oauthService: successfulOauthService ?? currentSession.oauthService ?? _oauthService, - oauthClientId: currentSession.oauthClientId, + oauthService: successfulOauthService ?? session.oauthService ?? _oauthService, + oauthClientId: session.oauthClientId, ); - await saveSession( - refreshedTokens, - makeActive: await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == currentSession.did, + final persistedTokens = await _persistRefreshedSession( + previousSession: session, + refreshedSession: refreshedTokens, ); log.i( - 'AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle} ' - 'using auth service ${refreshedTokens.oauthService ?? successfulOauthService ?? 'unknown'}', + 'AuthRepository: OAuth session refresh succeeded for ${persistedTokens.handle} ' + 'using auth service ${persistedTokens.oauthService ?? successfulOauthService ?? 'unknown'}', ); - return refreshedTokens; + return persistedTokens; } catch (error, stackTrace) { final shouldInvalidate = _shouldInvalidateSessionAfterRefreshFailure(error); log.e( @@ -469,18 +494,15 @@ class AuthRepository { stackTrace: stackTrace, ); if (shouldInvalidate) { - await _invalidateSession(currentSession); + await _invalidateSessionIfStillCurrent(session); } throw Exception('Failed to refresh OAuth session: $error'); } } try { - log.i('AuthRepository: Refreshing app password session for ${currentSession.handle}'); - final refreshed = await _appPasswordRefreshSession( - refreshJwt: currentSession.refreshToken!, - service: currentSession.service, - ); + log.i('AuthRepository: Refreshing app password session for ${session.handle}'); + final refreshed = await _appPasswordRefreshSession(refreshJwt: session.refreshToken!, service: session.service); final tokens = AuthTokens( accessToken: refreshed.data.accessJwt, @@ -488,17 +510,14 @@ class AuthRepository { expiresAt: refreshed.data.accessTokenJwt.exp, did: refreshed.data.did, handle: refreshed.data.handle, - displayName: currentSession.displayName, - service: currentSession.service, + displayName: session.displayName, + service: session.service, authMethod: AuthMethod.appPassword, ); - await saveSession( - tokens, - makeActive: await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == currentSession.did, - ); - log.i('AuthRepository: App password session refresh succeeded for ${tokens.handle}'); - return tokens; + final persistedTokens = await _persistRefreshedSession(previousSession: session, refreshedSession: tokens); + log.i('AuthRepository: App password session refresh succeeded for ${persistedTokens.handle}'); + return persistedTokens; } catch (error, stackTrace) { final shouldInvalidate = _shouldInvalidateSessionAfterRefreshFailure(error); log.e( @@ -508,7 +527,7 @@ class AuthRepository { stackTrace: stackTrace, ); if (shouldInvalidate) { - await _invalidateSession(currentSession); + await _invalidateSessionIfStillCurrent(session); } throw Exception('Failed to refresh session: $error'); } @@ -575,7 +594,7 @@ class AuthRepository { return false; } - final normalizedCallbackUri = _normalizeOAuthCallbackUri(callbackUri); + final normalizedCallbackUri = normalizeOAuthCallbackUri(callbackUri); if (normalizedCallbackUri == null) { log.w('AuthRepository: Ignoring unsupported OAuth callback URI ${_sanitizeUriForLog(callbackUri)}'); return false; @@ -584,7 +603,7 @@ class AuthRepository { final joiningInFlightExchange = _pendingOAuthCallbackExchange != null; try { log.i('AuthRepository: Processing OAuth callback URI ${_sanitizeUriForLog(normalizedCallbackUri)}'); - final tokens = await _runOAuthCallbackExchangeOnce(normalizedCallbackUri, _handleOAuthCallback); + final tokens = await runOAuthCallbackExchangeOnce(normalizedCallbackUri, _handleOAuthCallback); if (_oauthCompleter?.isCompleted == false) { _oauthCompleter?.complete(tokens); } @@ -602,7 +621,8 @@ class AuthRepository { } } - Future _runOAuthCallbackExchangeOnce( + @visibleForTesting + Future runOAuthCallbackExchangeOnce( Uri normalizedCallbackUri, Future Function(String callbackUrl) exchangeCallback, ) async { @@ -679,7 +699,8 @@ class AuthRepository { ); } - Future _resolveServiceForIdentifier(String identifier) async { + @visibleForTesting + Future resolveServiceForIdentifier(String identifier) async { log.d('AuthRepository: Resolving AT Protocol service for $identifier'); final resolvedIdentity = await _resolveIdentityForIdentifier(identifier); log.d('AuthRepository: Resolved identifier $identifier to DID ${resolvedIdentity.did}'); @@ -750,18 +771,6 @@ class AuthRepository { return (await client.identity.resolveHandle(handle: handle)).data.did; } - String _identifierValidationMessage(AtProtoIdentifierValidationError validationError) { - return switch (validationError.code) { - AtProtoIdentifierValidationErrorCode.empty => 'Enter a Bluesky handle or DID.', - AtProtoIdentifierValidationErrorCode.unsupportedDid => - 'Unsupported DID format. Use a did:plc:... or did:web:... identifier.', - AtProtoIdentifierValidationErrorCode.invalidDid => - 'Invalid DID format. Enter a complete did:plc:... or did:web:... identifier.', - AtProtoIdentifierValidationErrorCode.invalidHandle => - 'Invalid handle format. Enter a full handle like username.bsky.social.', - }; - } - AuthIdentifierResolutionException _handleResolutionFailureForIdentifier( String identifier, atcore.InvalidRequestException error, @@ -884,7 +893,7 @@ class AuthRepository { } Future _launchUrl(Uri url) async { - final launchMode = _oauthLaunchModeForPlatform(isWeb: kIsWeb, platform: defaultTargetPlatform); + final launchMode = oauthLaunchModeForPlatform(isWeb: kIsWeb, platform: defaultTargetPlatform); log.d('AuthRepository: Launching OAuth URL ${_sanitizeUriForLog(url)} with mode $launchMode'); if (!await _launchUrlWithMode(url, launchMode)) { @@ -915,14 +924,10 @@ class AuthRepository { } } - @visibleForTesting - static LaunchMode oauthLaunchModeForTest({required bool isWeb, required TargetPlatform platform}) { - return _oauthLaunchModeForPlatform(isWeb: isWeb, platform: platform); - } - /// ATProto OAuth providers can enforce browser-like fetch metadata semantics. /// Prefer the system browser app on mobile for consistent behavior. - static LaunchMode _oauthLaunchModeForPlatform({required bool isWeb, required TargetPlatform platform}) { + @visibleForTesting + static LaunchMode oauthLaunchModeForPlatform({required bool isWeb, required TargetPlatform platform}) { if (isWeb) { return LaunchMode.platformDefault; } @@ -935,7 +940,7 @@ class AuthRepository { } @visibleForTesting - Future dismissOAuthBrowserForTest(LaunchMode mode) async { + Future dismissOAuthBrowserForLaunchMode(LaunchMode mode) async { _oauthLaunchMode = mode; await _dismissOAuthBrowserIfNeeded(); } @@ -950,7 +955,8 @@ class AuthRepository { redirectUri.path == _httpsOAuthRedirectPath; } - Uri? _normalizeOAuthCallbackUri(Uri callbackUri) { + @visibleForTesting + Uri? normalizeOAuthCallbackUri(Uri callbackUri) { if (_isSupportedCustomSchemeRedirect(callbackUri)) { return callbackUri; } @@ -991,7 +997,8 @@ class AuthRepository { (queryParameters.containsKey('code') || queryParameters.containsKey('error')); } - Uri _selectOAuthRedirectUriTemplate( + @visibleForTesting + Uri selectOAuthRedirectUriTemplate( List redirectUris, { required bool isAndroid, required bool httpsAndroidCallbackEnabled, @@ -1033,34 +1040,6 @@ class AuthRepository { ); } - @visibleForTesting - Uri? normalizeOAuthCallbackUriForTest(Uri callbackUri) => _normalizeOAuthCallbackUri(callbackUri); - - @visibleForTesting - Future runOAuthCallbackExchangeOnceForTest( - Uri normalizedCallbackUri, - Future Function(String callbackUrl) exchangeCallback, - ) { - return _runOAuthCallbackExchangeOnce(normalizedCallbackUri, exchangeCallback); - } - - @visibleForTesting - Uri selectOAuthRedirectUriTemplateForTest( - List redirectUris, { - required bool isAndroid, - required bool httpsAndroidCallbackEnabled, - required bool isIos, - required bool httpsIosCallbackEnabled, - }) { - return _selectOAuthRedirectUriTemplate( - redirectUris, - isAndroid: isAndroid, - httpsAndroidCallbackEnabled: httpsAndroidCallbackEnabled, - isIos: isIos, - httpsIosCallbackEnabled: httpsIosCallbackEnabled, - ); - } - String _sanitizeUriForLog(Uri uri) { return uri.replace(query: null, fragment: null).toString(); } @@ -1072,6 +1051,120 @@ class AuthRepository { } } + Future _persistRefreshedSession({ + required AuthTokens previousSession, + required AuthTokens refreshedSession, + }) async { + final mergedSession = _mergeRefreshedSession(previousSession: previousSession, refreshedSession: refreshedSession); + final previousRefreshToken = previousSession.refreshToken; + final makeActive = await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == previousSession.did; + if (previousRefreshToken == null) { + await saveSession(mergedSession, makeActive: makeActive); + return mergedSession; + } + + final updated = await _database.updateAccountSessionIfRefreshTokenMatches( + previousSession.did, + expectedRefreshToken: previousRefreshToken, + handle: mergedSession.handle, + accessToken: mergedSession.accessToken, + refreshToken: mergedSession.refreshToken ?? previousRefreshToken, + expiresAt: mergedSession.expiresAt, + displayName: mergedSession.displayName, + service: mergedSession.service, + oauthService: mergedSession.oauthService, + oauthClientId: mergedSession.oauthClientId, + dpopNonce: mergedSession.dpopNonce, + dpopPublicKey: mergedSession.dpopPublicKey, + dpopPrivateKey: mergedSession.dpopPrivateKey, + ); + if (updated) { + return mergedSession; + } + + final storedAccount = await _database.getAccount(previousSession.did); + if (storedAccount != null && storedAccount.refreshToken != previousRefreshToken) { + log.w( + 'AuthRepository: Refresh persistence lost token race for ${previousSession.handle}; ' + 'using newer stored session.', + ); + return _tokensFromAccount(storedAccount); + } + + log.w( + 'AuthRepository: Refresh compare-and-swap found no current row for ${previousSession.handle}; ' + 'falling back to session upsert.', + ); + await saveSession(mergedSession, makeActive: makeActive); + return mergedSession; + } + + AuthTokens _mergeRefreshedSession({required AuthTokens previousSession, required AuthTokens refreshedSession}) { + return AuthTokens( + accessToken: refreshedSession.accessToken, + refreshToken: refreshedSession.refreshToken ?? previousSession.refreshToken, + expiresAt: refreshedSession.expiresAt ?? previousSession.expiresAt, + did: refreshedSession.did, + handle: refreshedSession.handle, + displayName: refreshedSession.displayName ?? previousSession.displayName, + service: refreshedSession.service ?? previousSession.service, + oauthService: refreshedSession.oauthService ?? previousSession.oauthService, + oauthClientId: refreshedSession.oauthClientId ?? previousSession.oauthClientId, + dpopNonce: refreshedSession.dpopNonce ?? previousSession.dpopNonce, + dpopPublicKey: refreshedSession.dpopPublicKey ?? previousSession.dpopPublicKey, + dpopPrivateKey: refreshedSession.dpopPrivateKey ?? previousSession.dpopPrivateKey, + authMethod: refreshedSession.authMethod, + ); + } + + Future _invalidateSessionIfStillCurrent(AuthTokens tokens) async { + final storedAccount = await _database.getAccount(tokens.did); + if (storedAccount == null) { + log.w('AuthRepository: Skipping session invalidation for ${tokens.handle}; account is no longer stored.'); + return; + } + + if (storedAccount.refreshToken != tokens.refreshToken) { + log.w( + 'AuthRepository: Skipping session invalidation for ${tokens.handle}; ' + 'stored refresh token changed after this refresh attempt began.', + ); + return; + } + + await _invalidateSession(tokens); + } + + Future _storedSessionIfRefreshTokenChanged(AuthTokens tokens) async { + final storedAccount = await _database.getAccount(tokens.did); + if (storedAccount == null || storedAccount.refreshToken == tokens.refreshToken) { + return null; + } + return _tokensFromAccount(storedAccount); + } + + AuthTokens _tokensFromAccount(Account account) { + final authMethod = account.dpopPrivateKey != null && account.dpopPublicKey != null + ? AuthMethod.oauth + : AuthMethod.appPassword; + + return AuthTokens( + accessToken: account.accessToken, + refreshToken: account.refreshToken, + expiresAt: account.expiresAt, + did: account.did, + handle: account.handle, + displayName: account.displayName, + service: account.service, + oauthService: authMethod == AuthMethod.oauth ? normalizeAtprotoServiceHost(account.oauthService) : null, + oauthClientId: authMethod == AuthMethod.oauth ? account.oauthClientId : null, + dpopNonce: account.dpopNonce, + dpopPublicKey: account.dpopPublicKey, + dpopPrivateKey: account.dpopPrivateKey, + authMethod: authMethod, + ); + } + bool _shouldInvalidateSessionAfterRefreshFailure(Object error) { if (error is _SessionRefreshException) { return error.shouldInvalidateSession; @@ -1148,20 +1241,6 @@ class AuthRepository { } } - OAuthSession _restoreOAuthSession({ - required AuthTokens currentSession, - required String publicKey, - required String privateKey, - }) { - return atcore.restoreOAuthSession( - accessToken: currentSession.accessToken, - refreshToken: currentSession.refreshToken!, - dPoPNonce: currentSession.dpopNonce, - publicKey: publicKey, - privateKey: privateKey, - ); - } - static Future _defaultOAuthRefreshSession({ required OAuthClientMetadata metadata, required String service, @@ -1208,7 +1287,8 @@ class AuthRepository { return message.length <= 240 ? message : '${message.substring(0, 237)}...'; } - static List _oauthRefreshServiceCandidates({required String? storedAuthService, required String? issuer}) { + @visibleForTesting + static List oauthRefreshServiceCandidates({required String? storedAuthService, required String? issuer}) { final candidates = {}; final issuerHost = normalizeAtprotoServiceHost(issuer); if (issuerHost != null) { @@ -1225,7 +1305,8 @@ class AuthRepository { return candidates.toList(growable: false); } - static List _oauthAuthorizeServiceCandidates({ + @visibleForTesting + static List oauthAuthorizeServiceCandidates({ required String? preferredAuthService, required String? resolvedPdsHost, required String? resolvedAuthService, @@ -1252,28 +1333,4 @@ class AuthRepository { candidates.add(_fallbackService); return candidates.toList(growable: false); } - - @visibleForTesting - static List oauthRefreshServiceCandidatesForTest({ - required String? storedAuthService, - required String? issuer, - }) { - return _oauthRefreshServiceCandidates(storedAuthService: storedAuthService, issuer: issuer); - } - - @visibleForTesting - static List oauthAuthorizeServiceCandidatesForTest({ - required String? preferredAuthService, - required String? resolvedPdsHost, - required String? resolvedAuthService, - }) { - return _oauthAuthorizeServiceCandidates( - preferredAuthService: preferredAuthService, - resolvedPdsHost: resolvedPdsHost, - resolvedAuthService: resolvedAuthService, - ); - } - - @visibleForTesting - Future resolveServiceForIdentifierForTest(String identifier) => _resolveServiceForIdentifier(identifier); } diff --git a/lib/features/feed/cubit/feed_preferences_cubit.dart b/lib/features/feed/cubit/feed_preferences_cubit.dart index a64e80a..3fb7d31 100644 --- a/lib/features/feed/cubit/feed_preferences_cubit.dart +++ b/lib/features/feed/cubit/feed_preferences_cubit.dart @@ -25,14 +25,14 @@ class FeedPreferencesCubit extends Cubit { final AppDatabase _database; final String _accountDid; - Future loadPreferences() async { + Future loadPreferences({bool emitCachedFirst = true}) async { log.d('FeedPreferencesCubit: Loading feed preferences for $_accountDid'); _safeEmit(state.copyWith(status: FeedPreferencesStatus.loading)); try { final cachedFeeds = await _database.getSavedFeeds(_accountDid); - if (cachedFeeds.isNotEmpty) { + if (emitCachedFirst && cachedFeeds.isNotEmpty) { final feeds = cachedFeeds.map(_mapFromCached).toList(); log.d('FeedPreferencesCubit: Loaded ${feeds.length} cached feeds for $_accountDid'); _emitLoaded(feeds); @@ -57,12 +57,13 @@ class FeedPreferencesCubit extends Cubit { final cachedFeeds = await _database.getSavedFeeds(_accountDid); if (cachedFeeds.isNotEmpty) { final feeds = cachedFeeds.map(_mapFromCached).toList(); + const message = 'Could not refresh feed preferences; showing cached feeds.'; log.w( 'FeedPreferencesCubit: Falling back to ${feeds.length} cached feeds for $_accountDid after load failure', error: e, stackTrace: stackTrace, ); - _emitLoaded(feeds); + _emitLoaded(feeds, message: message); await _hydrateGeneratorViews(feeds); } else { log.e( @@ -206,8 +207,10 @@ class FeedPreferencesCubit extends Cubit { String _generateId() => const Uuid().v4(); - void _emitLoaded(List feeds) { - _safeEmit(FeedPreferencesState.loaded(feeds: feeds, generatorViews: _retainGeneratorViews(feeds))); + void _emitLoaded(List feeds, {String? message}) { + _safeEmit( + FeedPreferencesState.loaded(feeds: feeds, generatorViews: _retainGeneratorViews(feeds), message: message), + ); } List _ensureDefaultFeeds(List feeds) { @@ -262,16 +265,23 @@ class FeedPreferencesCubit extends Cubit { final chunkViews = await _feedRepository.getFeedGenerators(chunk); generatorViews.addAll(chunkViews); continue; - } catch (_) { + } catch (error, stackTrace) { log.d( 'FeedPreferencesCubit: Batch hydration failed for ${chunk.length} generators, falling back to individual fetches for $_accountDid', + error: error, + stackTrace: stackTrace, ); } for (final feedUri in chunk) { try { generatorViews.add(await _feedRepository.getFeedGenerator(feedUri)); - } catch (_) { + } catch (error, stackTrace) { + log.w( + 'FeedPreferencesCubit: Failed to hydrate feed generator $feedUri for $_accountDid', + error: error, + stackTrace: stackTrace, + ); continue; } } @@ -334,8 +344,11 @@ class FeedPreferencesState extends Equatable { const FeedPreferencesState.initial() : this._(status: FeedPreferencesStatus.initial); - const FeedPreferencesState.loaded({required List feeds, List generatorViews = const []}) - : this._(status: FeedPreferencesStatus.loaded, feeds: feeds, generatorViews: generatorViews); + const FeedPreferencesState.loaded({ + required List feeds, + List generatorViews = const [], + String? message, + }) : this._(status: FeedPreferencesStatus.loaded, feeds: feeds, generatorViews: generatorViews, message: message); const FeedPreferencesState.error({required String message}) : this._(status: FeedPreferencesStatus.error, message: message); diff --git a/lib/features/feed/presentation/feed_management_screen.dart b/lib/features/feed/presentation/feed_management_screen.dart index 8045e34..f99eda8 100644 --- a/lib/features/feed/presentation/feed_management_screen.dart +++ b/lib/features/feed/presentation/feed_management_screen.dart @@ -28,6 +28,7 @@ class _FeedManagementScreenState extends State { @override void initState() { super.initState(); + context.read().loadPreferences(emitCachedFirst: false); _loadSuggestedFeeds(); } @@ -54,6 +55,11 @@ class _FeedManagementScreenState extends State { actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done'))], ), body: BlocConsumer( + listenWhen: (previous, current) => + current.status == FeedPreferencesStatus.saveError || + (current.status == FeedPreferencesStatus.loaded && + current.message != null && + current.message != previous.message), listener: (context, state) { if (state.status == FeedPreferencesStatus.saveError) { showAppSnackBar( @@ -62,6 +68,8 @@ class _FeedManagementScreenState extends State { actionLabel: 'Dismiss', onAction: () => context.read().clearError(), ); + } else if (state.status == FeedPreferencesStatus.loaded && state.message != null) { + showAppSnackBar(context, state.message!); } }, builder: (context, state) { diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index a614099..4b2ea26 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -212,6 +212,48 @@ void main() { expect(retrieved.dpopNonce, equals('nonce-1')); }); + test('should preserve nullable session fields when compare-and-swap update omits them', () async { + final expiresAt = DateTime.utc(2026, 5, 10, 12); + final account = AccountsCompanion.insert( + did: 'did:plc:oauth123', + handle: 'user.bsky.social', + accessToken: 'old-access', + refreshToken: const Value('old-refresh'), + displayName: const Value('Stored User'), + service: const Value('porcini.us-east.host.bsky.network'), + oauthService: const Value('bsky.social'), + oauthClientId: const Value('https://lazurite.stormlightlabs.org/client-metadata.json'), + dpopNonce: const Value('old-nonce'), + dpopPublicKey: const Value('public-key'), + dpopPrivateKey: const Value('private-key'), + expiresAt: Value(expiresAt), + ); + + await database.insertAccount(account); + final updated = await database.updateAccountSessionIfRefreshTokenMatches( + 'did:plc:oauth123', + expectedRefreshToken: 'old-refresh', + handle: 'user.bsky.social', + accessToken: 'new-access', + refreshToken: 'new-refresh', + ); + + expect(updated, isTrue); + + final retrieved = await database.getAccount('did:plc:oauth123'); + expect(retrieved, isNotNull); + expect(retrieved!.accessToken, equals('new-access')); + expect(retrieved.refreshToken, equals('new-refresh')); + expect(retrieved.displayName, equals('Stored User')); + expect(retrieved.service, equals('porcini.us-east.host.bsky.network')); + expect(retrieved.oauthService, equals('bsky.social')); + expect(retrieved.oauthClientId, equals('https://lazurite.stormlightlabs.org/client-metadata.json')); + expect(retrieved.dpopNonce, equals('old-nonce')); + expect(retrieved.dpopPublicKey, equals('public-key')); + expect(retrieved.dpopPrivateKey, equals('private-key')); + expect(retrieved.expiresAt?.toUtc(), equals(expiresAt)); + }); + test('should persist oauth service separately from pds service', () async { final account = AccountsCompanion.insert( did: 'did:plc:oauth123', diff --git a/test/core/network/poptart_client_adapter_test.dart b/test/core/network/poptart_client_adapter_test.dart new file mode 100644 index 0000000..db6e8c4 --- /dev/null +++ b/test/core/network/poptart_client_adapter_test.dart @@ -0,0 +1,284 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:poptart_lex/app/bsky/actor.dart' as actor_methods; +import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/feed.dart' as feed_methods; +import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; + +void main() { + group('Bluesky poptart adapter', () { + test('feed.like.create encodes createRecord input through descriptor type conversion', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response( + '{"uri":"at://did:plc:test/app.bsky.feed.like/like1","cid":"like-cid"}', + 200, + request: http.Request('POST', url), + ); + }, + ); + + final response = await bluesky.feed.like.create( + subject: RepoStrongRef(cid: 'post-cid', uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/post1')), + createdAt: DateTime.utc(2026, 5, 10, 15, 8, 56), + ); + + expect(response.data.uri.toString(), 'at://did:plc:test/app.bsky.feed.like/like1'); + final body = jsonDecode(capturedBody! as String) as Map; + expect(body['repo'], 'did:plc:test'); + expect(body['collection'], 'app.bsky.feed.like'); + expect(body['record'], { + r'$type': 'app.bsky.feed.like', + 'subject': { + r'$type': 'com.atproto.repo.strongRef', + 'uri': 'at://did:plc:author/app.bsky.feed.post/post1', + 'cid': 'post-cid', + }, + 'createdAt': '2026-05-10T15:08:56.000Z', + }); + }); + + test('actor.putPreferences encodes procedure values through descriptor type conversion', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response('{}', 200, request: http.Request('POST', url)); + }, + ); + + const feed = SavedFeed( + id: 'feed-1', + type: SavedFeedType.knownValue(data: KnownSavedFeedType.feed), + value: 'at://did:plc:feed/app.bsky.feed.generator/news', + pinned: true, + ); + + await bluesky.actor.putPreferences( + preferences: [ + const UPreferences.savedFeedsPrefV2(data: SavedFeedsPrefV2(items: [feed])), + ], + ); + + final body = jsonDecode(capturedBody! as String) as Map; + expect(body['preferences'], [ + { + r'$type': 'app.bsky.actor.defs#savedFeedsPrefV2', + 'items': [ + { + r'$type': 'app.bsky.actor.defs#savedFeed', + 'id': 'feed-1', + 'type': 'feed', + 'value': 'at://did:plc:feed/app.bsky.feed.generator/news', + 'pinned': true, + }, + ], + }, + ]); + }); + + test('record put and delete encode repo write inputs through descriptor type conversion', () async { + final capturedBodies = >[]; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBodies.add(jsonDecode(body! as String) as Map); + if (url.path.endsWith('com.atproto.repo.deleteRecord')) { + return http.Response('{}', 200, request: http.Request('POST', url)); + } + return http.Response( + '{"uri":"at://did:plc:test/app.bsky.feed.post/post1","cid":"post-cid"}', + 200, + request: http.Request('POST', url), + ); + }, + ); + + await bluesky.feed.post.put( + rkey: 'post1', + record: {r'$type': 'app.bsky.feed.post', 'text': 'Hello', 'createdAt': DateTime.utc(2026, 5, 10, 15, 8, 56)}, + ); + await bluesky.feed.post.delete(rkey: 'post1'); + + expect(capturedBodies.first, { + 'repo': 'did:plc:test', + 'collection': 'app.bsky.feed.post', + 'rkey': 'post1', + 'record': {r'$type': 'app.bsky.feed.post', 'text': 'Hello', 'createdAt': '2026-05-10T15:08:56.000Z'}, + }); + expect(capturedBodies.last, {'repo': 'did:plc:test', 'collection': 'app.bsky.feed.post', 'rkey': 'post1'}); + }); + + test('feed.getFeedGenerators encodes query parameters through descriptor type conversion', () async { + Uri? capturedUrl; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + getClient: (url, {headers}) async { + capturedUrl = url; + return http.Response( + '{"feeds":[{"uri":"at://did:plc:feed/app.bsky.feed.generator/news","cid":"cid-feed","did":"did:web:feed.example","creator":{"did":"did:plc:feed","handle":"feed.example"},"displayName":"News","indexedAt":"2026-05-10T15:08:56.000Z"}]}', + 200, + request: http.Request('GET', url), + ); + }, + ); + + final feeds = await bluesky.feed.getFeedGenerators( + feeds: [AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/news')], + ); + + expect(feeds.data.feeds.single.displayName, 'News'); + expect(capturedUrl!.queryParametersAll['feeds'], ['at://did:plc:feed/app.bsky.feed.generator/news']); + }); + + test('actor.getPreferences omits empty query parameter maps for EmptyData descriptors', () async { + Uri? capturedUrl; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + getClient: (url, {headers}) async { + capturedUrl = url; + return http.Response('{"preferences":[]}', 200, request: http.Request('GET', url)); + }, + ); + + final response = await bluesky.actor.getPreferences(); + + expect(response.data.preferences, isEmpty); + expect(capturedUrl!.query, isEmpty); + }); + + test('no-argument EmptyData queries omit empty query parameter maps', () async { + final capturedUrls = []; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + getClient: (url, {headers}) async { + capturedUrls.add(url); + final path = url.path; + if (path.endsWith('app.bsky.notification.getUnreadCount')) { + return http.Response('{"count":3}', 200, request: http.Request('GET', url)); + } + if (path.endsWith('app.bsky.video.getUploadLimits')) { + return http.Response('{"canUpload":true}', 200, request: http.Request('GET', url)); + } + if (path.endsWith('com.atproto.server.getSession')) { + return http.Response( + '{"handle":"test.bsky.social","did":"did:plc:test"}', + 200, + request: http.Request('GET', url), + ); + } + return http.Response('{}', 404, request: http.Request('GET', url)); + }, + ); + + final unread = await bluesky.notification.getUnreadCount(); + final limits = await bluesky.video.getUploadLimits(); + final session = await bluesky.atproto.server.getSession(); + + expect(unread.data.count, 3); + expect(limits.data.canUpload, isTrue); + expect(session.data.did, 'did:plc:test'); + expect(capturedUrls.map((url) => url.query), everyElement(isEmpty)); + }); + + test('public call coerces map query parameters before forwarding to poptart', () async { + Uri? capturedUrl; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + getClient: (url, {headers}) async { + capturedUrl = url; + return http.Response( + '{"feeds":[{"uri":"at://did:plc:feed/app.bsky.feed.generator/news","cid":"cid-feed","did":"did:web:feed.example","creator":{"did":"did:plc:feed","handle":"feed.example"},"displayName":"News","indexedAt":"2026-05-10T15:08:56.000Z"}]}', + 200, + request: http.Request('GET', url), + ); + }, + ); + + final response = await (bluesky as dynamic).call( + feed_methods.appBskyFeedGetFeedGenerators, + parameters: { + 'feeds': [AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/news')], + }, + ); + + expect(response.data.feeds.single.displayName, 'News'); + expect(capturedUrl!.queryParametersAll['feeds'], ['at://did:plc:feed/app.bsky.feed.generator/news']); + }); + + test('public call omits empty query parameter maps for EmptyData descriptors', () async { + Uri? capturedUrl; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + getClient: (url, {headers}) async { + capturedUrl = url; + return http.Response('{"preferences":[]}', 200, request: http.Request('GET', url)); + }, + ); + + final response = await (bluesky as dynamic).call(actor_methods.appBskyActorGetPreferences, parameters: {}); + + expect(response.data.preferences, isEmpty); + expect(capturedUrl!.query, isEmpty); + }); + + test('public call coerces map procedure input before forwarding to poptart', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response('{}', 200, request: http.Request('POST', url)); + }, + ); + + const feed = SavedFeed( + id: 'feed-1', + type: SavedFeedType.knownValue(data: KnownSavedFeedType.feed), + value: 'at://did:plc:feed/app.bsky.feed.generator/news', + pinned: true, + ); + + await (bluesky as dynamic).call( + actor_methods.appBskyActorPutPreferences, + input: { + 'preferences': [ + const UPreferences.savedFeedsPrefV2(data: SavedFeedsPrefV2(items: [feed])), + ], + }, + ); + + final body = jsonDecode(capturedBody! as String) as Map; + expect(body['preferences'], [ + { + r'$type': 'app.bsky.actor.defs#savedFeedsPrefV2', + 'items': [ + { + r'$type': 'app.bsky.actor.defs#savedFeed', + 'id': 'feed-1', + 'type': 'feed', + 'value': 'at://did:plc:feed/app.bsky.feed.generator/news', + 'pinned': true, + }, + ], + }, + ]); + }); + }); +} diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 48ca421..7c41e96 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:poptart_core/poptart_core.dart' as atcore; -import 'package:poptart_oauth/poptart_oauth.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; @@ -10,6 +8,8 @@ import 'package:lazurite/core/network/slingshot_client.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; +import 'package:poptart_oauth/poptart_oauth.dart'; import 'package:url_launcher/url_launcher.dart'; class MockAppDatabase extends Mock implements AppDatabase {} @@ -30,6 +30,24 @@ void main() { setUp(() { mockDatabase = MockAppDatabase(); mockSlingshotClient = MockSlingshotClient(); + when(() => mockDatabase.getAccount(any())).thenAnswer((_) async => null); + when( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ).thenAnswer((_) async => false); authRepository = AuthRepository(database: mockDatabase); }); @@ -200,6 +218,196 @@ void main() { }); group('app password refresh', () { + test('coalesces concurrent refreshes for the same DID', () async { + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final refreshedAccessToken = _buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds + 3600, + iatEpochSeconds: nowEpochSeconds, + ); + final refreshStarted = Completer(); + final allowRefreshToComplete = Completer(); + var refreshCalls = 0; + authRepository = AuthRepository( + database: mockDatabase, + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async { + refreshCalls += 1; + refreshStarted.complete(); + await allowRefreshToComplete.future; + return _appPasswordRefreshResponse( + did: 'did:plc:abc123', + handle: 'user.bsky.social', + accessJwt: refreshedAccessToken, + refreshJwt: 'new-refresh-token', + ); + }, + ); + + const currentSession = AuthTokens( + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(currentSession)); + when( + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), + ).thenAnswer((_) async => currentSession.did); + when( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + currentSession.did, + expectedRefreshToken: currentSession.refreshToken!, + handle: 'user.bsky.social', + accessToken: refreshedAccessToken, + refreshToken: 'new-refresh-token', + expiresAt: any(named: 'expiresAt'), + displayName: null, + service: 'bsky.social', + oauthService: null, + oauthClientId: null, + dpopNonce: null, + dpopPublicKey: null, + dpopPrivateKey: null, + ), + ).thenAnswer((_) async => true); + + final firstRefresh = authRepository.refreshSession(currentSession); + await refreshStarted.future; + final secondRefresh = authRepository.refreshSession(currentSession); + allowRefreshToComplete.complete(); + + final refreshed = await Future.wait([firstRefresh, secondRefresh]); + + expect(refreshCalls, equals(1)); + expect(refreshed.map((tokens) => tokens?.refreshToken), everyElement('new-refresh-token')); + }); + + test('returns newer stored session when caller holds stale refresh token', () async { + authRepository = AuthRepository( + database: mockDatabase, + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async => + throw StateError('stale refresh token should not be used'), + ); + + const currentSession = AuthTokens( + accessToken: 'expired-access-token', + refreshToken: 'stale-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + final newerSession = AuthTokens( + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(newerSession)); + + final refreshed = await authRepository.refreshSession(currentSession); + + expect(refreshed, isNotNull); + expect(refreshed!.refreshToken, equals('new-refresh-token')); + verifyNever( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ); + }); + + test('uses newer stored session when compare-and-swap persistence loses a token race', () async { + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final refreshedAccessToken = _buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds + 3600, + iatEpochSeconds: nowEpochSeconds, + ); + authRepository = AuthRepository( + database: mockDatabase, + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async { + return _appPasswordRefreshResponse( + did: 'did:plc:abc123', + handle: 'user.bsky.social', + accessJwt: refreshedAccessToken, + refreshJwt: 'refresh-from-this-call', + ); + }, + ); + + const currentSession = AuthTokens( + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + final newerSession = AuthTokens( + accessToken: 'newer-access-token', + refreshToken: 'newer-refresh-token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + var getAccountCalls = 0; + when(() => mockDatabase.getAccount(currentSession.did)).thenAnswer((_) async { + getAccountCalls += 1; + return getAccountCalls == 1 ? _accountForTokens(currentSession) : _accountForTokens(newerSession); + }); + when( + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), + ).thenAnswer((_) async => currentSession.did); + when( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ).thenAnswer((_) async => false); + + final refreshed = await authRepository.refreshSession(currentSession); + + expect(refreshed, isNotNull); + expect(refreshed!.refreshToken, equals('newer-refresh-token')); + verifyNever(() => mockDatabase.insertAccount(any())); + }); + test('preserves account when refresh fails transiently', () async { authRepository = AuthRepository( database: mockDatabase, @@ -238,6 +446,9 @@ void main() { authMethod: AuthMethod.appPassword, ); + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(currentSession)); when(() => mockDatabase.deleteAccount(currentSession.did)).thenAnswer((_) async => 1); when( () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), @@ -249,11 +460,47 @@ void main() { verify(() => mockDatabase.deleteAccount(currentSession.did)).called(1); verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); }); + + test('does not invalidate account when rejected refresh token is already stale', () async { + authRepository = AuthRepository( + database: mockDatabase, + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async => + throw _unauthorizedRefreshException(), + ); + + const currentSession = AuthTokens( + accessToken: 'expired-access-token', + refreshToken: 'stale-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + const newerSession = AuthTokens( + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(newerSession)); + + final refreshed = await authRepository.refreshSession(currentSession); + + expect(refreshed, isNotNull); + expect(refreshed!.refreshToken, equals('new-refresh-token')); + verifyNever(() => mockDatabase.deleteAccount(any())); + verifyNever(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)); + }); }); group('oauth refresh', () { test('orders issuer host before stored auth host and deduplicates candidates', () { - final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( + final candidates = AuthRepository.oauthRefreshServiceCandidates( storedAuthService: 'https://bsky.social', issuer: 'https://bsky.social', ); @@ -262,7 +509,7 @@ void main() { }); test('uses stored oauth auth host when issuer is unavailable', () { - final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( + final candidates = AuthRepository.oauthRefreshServiceCandidates( storedAuthService: 'https://oauth.custom.example', issuer: null, ); @@ -344,6 +591,89 @@ void main() { verify(() => mockDatabase.insertAccount(any())).called(1); }); + test('preserves stored nullable OAuth fields when refresh does not re-fetch them', () async { + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final expiredAccessToken = _buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds - 3600, + iatEpochSeconds: nowEpochSeconds - 7200, + aud: 'did:web:porcini.us-east.host.bsky.network', + iss: 'https://bsky.social', + ); + final refreshedAccessToken = _buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds + 3600, + iatEpochSeconds: nowEpochSeconds, + aud: 'did:web:porcini.us-east.host.bsky.network', + iss: 'https://bsky.social', + ); + + authRepository = AuthRepository( + database: mockDatabase, + loadClientMetadata: (_) async => _testClientMetadata(), + oauthRefreshSession: + ({required OAuthClientMetadata metadata, required String service, required OAuthSession session}) async { + return OAuthSession( + accessToken: refreshedAccessToken, + refreshToken: session.refreshToken, + tokenType: 'DPoP', + scope: 'atproto', + expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)), + sub: session.sub, + $dPoPNonce: 'new-nonce', + $publicKey: session.$publicKey, + $privateKey: session.$privateKey, + ); + }, + ); + + final currentSession = AuthTokens( + accessToken: expiredAccessToken, + refreshToken: 'refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + displayName: 'Stored User', + service: 'porcini.us-east.host.bsky.network', + oauthService: 'bsky.social', + oauthClientId: AuthRepository.kClientId, + dpopNonce: 'old-nonce', + dpopPublicKey: 'public-key', + dpopPrivateKey: 'private-key', + authMethod: AuthMethod.oauth, + ); + + when( + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), + ).thenAnswer((_) async => currentSession.did); + when( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + currentSession.did, + expectedRefreshToken: currentSession.refreshToken!, + handle: currentSession.handle, + accessToken: refreshedAccessToken, + refreshToken: currentSession.refreshToken!, + expiresAt: any(named: 'expiresAt'), + displayName: currentSession.displayName, + service: currentSession.service, + oauthService: currentSession.oauthService, + oauthClientId: currentSession.oauthClientId, + dpopNonce: 'new-nonce', + dpopPublicKey: currentSession.dpopPublicKey, + dpopPrivateKey: currentSession.dpopPrivateKey, + ), + ).thenAnswer((_) async => true); + + final refreshed = await authRepository.refreshSession(currentSession); + + expect(refreshed, isNotNull); + expect(refreshed!.refreshToken, equals('refresh-token')); + expect(refreshed.displayName, equals('Stored User')); + expect(refreshed.service, equals('porcini.us-east.host.bsky.network')); + expect(refreshed.oauthClientId, equals(AuthRepository.kClientId)); + expect(refreshed.dpopNonce, equals('new-nonce')); + verifyNever(() => mockDatabase.insertAccount(any())); + }); + test('loads OAuth refresh metadata using stored oauthClientId', () async { final requestedClientIds = []; final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; @@ -554,6 +884,9 @@ void main() { authMethod: AuthMethod.oauth, ); + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(currentSession)); when(() => mockDatabase.deleteAccount(currentSession.did)).thenAnswer((_) async => 1); when( () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), @@ -566,6 +899,63 @@ void main() { verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); }); + test('does not invalidate OAuth account when rejected refresh token is already stale', () async { + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final expiredAccessToken = _buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds - 3600, + iatEpochSeconds: nowEpochSeconds - 7200, + aud: 'did:web:porcini.us-east.host.bsky.network', + iss: 'https://bsky.social', + ); + final newerAccessToken = _buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds + 3600, + iatEpochSeconds: nowEpochSeconds, + aud: 'did:web:porcini.us-east.host.bsky.network', + iss: 'https://bsky.social', + ); + + authRepository = AuthRepository( + database: mockDatabase, + loadClientMetadata: (_) async => _testClientMetadata(), + oauthRefreshSession: + ({required OAuthClientMetadata metadata, required String service, required OAuthSession session}) async { + throw const OAuthException('{"error":"invalid_grant"}'); + }, + ); + + final currentSession = AuthTokens( + accessToken: expiredAccessToken, + refreshToken: 'stale-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'porcini.us-east.host.bsky.network', + oauthService: 'bsky.social', + oauthClientId: AuthRepository.kClientId, + dpopNonce: 'nonce', + dpopPublicKey: 'public-key', + dpopPrivateKey: 'private-key', + authMethod: AuthMethod.oauth, + ); + final newerSession = currentSession.copyWith( + accessToken: newerAccessToken, + refreshToken: 'new-refresh-token', + dpopNonce: 'new-nonce', + ); + + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(newerSession)); + + final refreshed = await authRepository.refreshSession(currentSession); + + expect(refreshed, isNotNull); + expect(refreshed!.refreshToken, equals('new-refresh-token')); + verifyNever(() => mockDatabase.deleteAccount(any())); + verifyNever(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)); + }); + test('does not invalidate when only fallback OAuth candidates reject credentials', () async { final attemptedServices = []; final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; @@ -614,7 +1004,7 @@ void main() { group('oauth authorize candidates', () { test('prioritizes resolved auth service before provider preference', () { - final candidates = AuthRepository.oauthAuthorizeServiceCandidatesForTest( + final candidates = AuthRepository.oauthAuthorizeServiceCandidates( preferredAuthService: 'blacksky.community', resolvedPdsHost: 'https://porcini.us-east.host.bsky.network', resolvedAuthService: 'https://bsky.social', @@ -624,7 +1014,7 @@ void main() { }); test('deduplicates when preferred and resolved hosts match defaults', () { - final candidates = AuthRepository.oauthAuthorizeServiceCandidatesForTest( + final candidates = AuthRepository.oauthAuthorizeServiceCandidates( preferredAuthService: 'https://bsky.social', resolvedPdsHost: 'bsky.social', resolvedAuthService: 'bsky.social', @@ -643,7 +1033,7 @@ void main() { resolveHandleDid: (_) async => throw Exception('resolveHandle down'), ); - expect(() => authRepository.resolveServiceForIdentifierForTest('alice.bsky.social'), throwsA(isA())); + expect(() => authRepository.resolveServiceForIdentifier('alice.bsky.social'), throwsA(isA())); verifyNever(() => mockSlingshotClient.resolveMiniDoc(any())); }); @@ -666,7 +1056,7 @@ void main() { }, ); - final service = await authRepository.resolveServiceForIdentifierForTest('alice.bsky.social'); + final service = await authRepository.resolveServiceForIdentifier('alice.bsky.social'); expect(service, equals('pds.alice.example')); verify(() => mockSlingshotClient.resolveMiniDoc('alice.bsky.social')).called(1); @@ -681,7 +1071,7 @@ void main() { isA().having( (error) => error.toString(), 'message', - contains('Invalid handle format'), + contains('Enter a full handle like username.bsky.social'), ), ), ); @@ -715,7 +1105,7 @@ void main() { }, ); - final service = await authRepository.resolveServiceForIdentifierForTest('DID:PLC:ABC123'); + final service = await authRepository.resolveServiceForIdentifier('DID:PLC:ABC123'); expect(service, equals('pds.example')); }); @@ -726,7 +1116,7 @@ void main() { isA().having( (error) => error.toString(), 'message', - contains('Invalid DID format'), + contains('Enter a complete DID like did:plc:... or did:web:...'), ), ), ); @@ -735,7 +1125,7 @@ void main() { group('oauth callback normalization', () { test('accepts canonical custom scheme callback URI', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest( + final normalized = authRepository.normalizeOAuthCallbackUri( Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), ); @@ -745,16 +1135,14 @@ void main() { }); test('normalizes path-only callback URI to canonical custom scheme', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest( - Uri.parse('/oauth/callback?code=abc&state=xyz'), - ); + final normalized = authRepository.normalizeOAuthCallbackUri(Uri.parse('/oauth/callback?code=abc&state=xyz')); expect(normalized, isNotNull); expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); }); test('normalizes authority-style custom scheme callback URI to canonical custom scheme', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest( + final normalized = authRepository.normalizeOAuthCallbackUri( Uri.parse('org.stormlightlabs.lazurite://oauth/callback?code=abc&state=xyz'), ); @@ -763,20 +1151,20 @@ void main() { }); test('normalizes compatibility callback path to canonical custom scheme', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest(Uri.parse('/callback?code=abc&state=xyz')); + final normalized = authRepository.normalizeOAuthCallbackUri(Uri.parse('/callback?code=abc&state=xyz')); expect(normalized, isNotNull); expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); }); test('rejects path-only callback without oauth response parameters', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest(Uri.parse('/callback?foo=bar')); + final normalized = authRepository.normalizeOAuthCallbackUri(Uri.parse('/callback?foo=bar')); expect(normalized, isNull); }); test('accepts exact HTTPS callback URI with oauth query parameters', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest( + final normalized = authRepository.normalizeOAuthCallbackUri( Uri.parse( 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social', ), @@ -791,7 +1179,7 @@ void main() { }); test('rejects HTTPS callback URI with unexpected host', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest( + final normalized = authRepository.normalizeOAuthCallbackUri( Uri.parse('https://example.com/oauth/callback?code=abc&state=xyz'), ); @@ -799,7 +1187,7 @@ void main() { }); test('rejects HTTPS callback URI with unexpected path', () { - final normalized = authRepository.normalizeOAuthCallbackUriForTest( + final normalized = authRepository.normalizeOAuthCallbackUri( Uri.parse('https://lazurite.stormlightlabs.org/callback?code=abc&state=xyz'), ); @@ -819,11 +1207,11 @@ void main() { return exchangeCompleter.future; } - final firstResult = authRepository.runOAuthCallbackExchangeOnceForTest( + final firstResult = authRepository.runOAuthCallbackExchangeOnce( Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), exchange, ); - final secondResult = authRepository.runOAuthCallbackExchangeOnceForTest( + final secondResult = authRepository.runOAuthCallbackExchangeOnce( Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), exchange, ); @@ -841,7 +1229,7 @@ void main() { group('oauth redirect URI selection', () { test('prefers HTTPS callback on Android when flag is enabled', () { - final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + final selected = authRepository.selectOAuthRedirectUriTemplate( const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], isAndroid: true, httpsAndroidCallbackEnabled: true, @@ -853,7 +1241,7 @@ void main() { }); test('uses custom scheme callback on Android when HTTPS flag is disabled', () { - final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + final selected = authRepository.selectOAuthRedirectUriTemplate( const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], isAndroid: true, httpsAndroidCallbackEnabled: false, @@ -865,7 +1253,7 @@ void main() { }); test('uses custom scheme callback when HTTPS callback is unavailable', () { - final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + final selected = authRepository.selectOAuthRedirectUriTemplate( const ['org.stormlightlabs.lazurite:/oauth/callback'], isAndroid: true, httpsAndroidCallbackEnabled: true, @@ -877,7 +1265,7 @@ void main() { }); test('uses HTTPS callback when custom scheme callback is unavailable', () { - final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + final selected = authRepository.selectOAuthRedirectUriTemplate( const ['https://lazurite.stormlightlabs.org/oauth/callback'], isAndroid: true, httpsAndroidCallbackEnabled: true, @@ -889,7 +1277,7 @@ void main() { }); test('prefers HTTPS callback on iOS when flag is enabled', () { - final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + final selected = authRepository.selectOAuthRedirectUriTemplate( const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], isAndroid: false, httpsAndroidCallbackEnabled: true, @@ -901,7 +1289,7 @@ void main() { }); test('uses custom scheme callback on iOS when HTTPS flag is disabled', () { - final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + final selected = authRepository.selectOAuthRedirectUriTemplate( const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], isAndroid: false, httpsAndroidCallbackEnabled: true, @@ -914,7 +1302,7 @@ void main() { test('throws when no supported callback URI is present', () { expect( - () => authRepository.selectOAuthRedirectUriTemplateForTest( + () => authRepository.selectOAuthRedirectUriTemplate( const ['https://example.com/oauth/callback'], isAndroid: true, httpsAndroidCallbackEnabled: true, @@ -987,32 +1375,32 @@ void main() { group('oauth browser launch mode', () { test('uses external application on iOS', () { expect( - AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.iOS), + AuthRepository.oauthLaunchModeForPlatform(isWeb: false, platform: TargetPlatform.iOS), equals(LaunchMode.externalApplication), ); }); test('uses external application on Android', () { expect( - AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.android), + AuthRepository.oauthLaunchModeForPlatform(isWeb: false, platform: TargetPlatform.android), equals(LaunchMode.externalApplication), ); }); test('uses external application on non-mobile native platforms', () { expect( - AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.macOS), + AuthRepository.oauthLaunchModeForPlatform(isWeb: false, platform: TargetPlatform.macOS), equals(LaunchMode.externalApplication), ); expect( - AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.windows), + AuthRepository.oauthLaunchModeForPlatform(isWeb: false, platform: TargetPlatform.windows), equals(LaunchMode.externalApplication), ); }); test('uses platform default mode on web', () { expect( - AuthRepository.oauthLaunchModeForTest(isWeb: true, platform: TargetPlatform.iOS), + AuthRepository.oauthLaunchModeForPlatform(isWeb: true, platform: TargetPlatform.iOS), equals(LaunchMode.platformDefault), ); }); @@ -1034,7 +1422,7 @@ void main() { }, ); - await authRepository.dismissOAuthBrowserForTest(LaunchMode.inAppBrowserView); + await authRepository.dismissOAuthBrowserForLaunchMode(LaunchMode.inAppBrowserView); expect(supportChecks, equals(1)); expect(closeCalls, equals(1)); @@ -1055,7 +1443,7 @@ void main() { }, ); - await authRepository.dismissOAuthBrowserForTest(LaunchMode.externalApplication); + await authRepository.dismissOAuthBrowserForLaunchMode(LaunchMode.externalApplication); expect(supportChecks, equals(0)); expect(closeCalls, equals(0)); @@ -1064,49 +1452,78 @@ void main() { }); } -atcore.InvalidRequestException _invalidResolveHandleRequestException() { - return atcore.InvalidRequestException( - atcore.XRPCResponse( - headers: const {}, - status: atcore.HttpStatus.badRequest, - request: atcore.XRPCRequest( - method: atcore.HttpMethod.get, - url: Uri.https('bsky.social', '/xrpc/com.atproto.identity.resolveHandle'), - ), - rateLimit: atcore.RateLimit.unlimited(), - data: const atcore.XRPCError(error: 'InvalidRequest', message: 'Could not resolve handle'), +atcore.InvalidRequestException _invalidResolveHandleRequestException() => atcore.InvalidRequestException( + atcore.XRPCResponse( + headers: const {}, + status: atcore.HttpStatus.badRequest, + request: atcore.XRPCRequest( + method: atcore.HttpMethod.get, + url: Uri.https('bsky.social', '/xrpc/com.atproto.identity.resolveHandle'), ), - ); -} - -atcore.UnauthorizedException _unauthorizedRefreshException() { - return atcore.UnauthorizedException( - atcore.XRPCResponse( - headers: const {}, - status: atcore.HttpStatus.unauthorized, - request: atcore.XRPCRequest( - method: atcore.HttpMethod.post, - url: Uri.https('bsky.social', '/xrpc/com.atproto.server.refreshSession'), - ), - rateLimit: atcore.RateLimit.unlimited(), - data: const atcore.XRPCError(error: 'ExpiredToken', message: 'Refresh token rejected'), + rateLimit: atcore.RateLimit.unlimited(), + data: const atcore.XRPCError(error: 'InvalidRequest', message: 'Could not resolve handle'), + ), +); + +atcore.UnauthorizedException _unauthorizedRefreshException() => atcore.UnauthorizedException( + atcore.XRPCResponse( + headers: const {}, + status: atcore.HttpStatus.unauthorized, + request: atcore.XRPCRequest( + method: atcore.HttpMethod.post, + url: Uri.https('bsky.social', '/xrpc/com.atproto.server.refreshSession'), ), - ); -} - -OAuthClientMetadata _testClientMetadata() { - return const OAuthClientMetadata( - clientId: AuthRepository.kClientId, - applicationType: 'native', - clientName: 'Lazurite Test', - clientUri: 'https://lazurite.stormlightlabs.org', - redirectUris: ['https://lazurite.stormlightlabs.org/oauth/callback', 'org.stormlightlabs.lazurite:/oauth/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code', 'refresh_token'], - scope: 'atproto', - tokenEndpointAuthMethod: 'none', - ); -} + rateLimit: atcore.RateLimit.unlimited(), + data: const atcore.XRPCError(error: 'ExpiredToken', message: 'Refresh token rejected'), + ), +); + +atcore.XRPCResponse _appPasswordRefreshResponse({ + required String did, + required String handle, + required String accessJwt, + required String refreshJwt, +}) => atcore.XRPCResponse( + headers: const {}, + status: atcore.HttpStatus.ok, + request: atcore.XRPCRequest( + method: atcore.HttpMethod.post, + url: Uri.https('bsky.social', '/xrpc/com.atproto.server.refreshSession'), + ), + rateLimit: atcore.RateLimit.unlimited(), + data: atcore.Session(did: did, handle: handle, accessJwt: accessJwt, refreshJwt: refreshJwt), +); + +OAuthClientMetadata _testClientMetadata() => const OAuthClientMetadata( + clientId: AuthRepository.kClientId, + applicationType: 'native', + clientName: 'Lazurite Test', + clientUri: 'https://lazurite.stormlightlabs.org', + redirectUris: ['https://lazurite.stormlightlabs.org/oauth/callback', 'org.stormlightlabs.lazurite:/oauth/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code', 'refresh_token'], + scope: 'atproto', + tokenEndpointAuthMethod: 'none', +); + +Account _accountForTokens(AuthTokens tokens) => Account( + did: tokens.did, + handle: tokens.handle, + service: tokens.service, + oauthService: tokens.oauthService, + oauthClientId: tokens.oauthClientId, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + dpopPublicKey: tokens.dpopPublicKey, + dpopPrivateKey: tokens.dpopPrivateKey, + dpopNonce: tokens.dpopNonce, + displayName: tokens.displayName, + expiresAt: tokens.expiresAt, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), +); + +String _encodePart(Map value) => base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); String _buildJwt({ required String sub, @@ -1115,12 +1532,8 @@ String _buildJwt({ String? aud, String? iss, }) { - String encodePart(Map value) { - return base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - } - - final header = encodePart(const {'alg': 'none', 'typ': 'JWT'}); - final payload = encodePart({ + final header = _encodePart(const {'alg': 'none', 'typ': 'JWT'}); + final payload = _encodePart({ 'sub': sub, 'exp': expEpochSeconds, 'iat': iatEpochSeconds, diff --git a/test/features/feed/cubit/feed_preferences_cubit_test.dart b/test/features/feed/cubit/feed_preferences_cubit_test.dart index d6cf79f..71d00c3 100644 --- a/test/features/feed/cubit/feed_preferences_cubit_test.dart +++ b/test/features/feed/cubit/feed_preferences_cubit_test.dart @@ -147,7 +147,56 @@ void main() { isA().having((s) => s.status, 'status', FeedPreferencesStatus.loading), isA() .having((s) => s.status, 'status', FeedPreferencesStatus.loaded) - .having((s) => s.feeds.length, 'feeds.length', 1), + .having((s) => s.feeds.length, 'feeds.length', 1) + .having((s) => s.message, 'message', null), + isA() + .having((s) => s.status, 'status', FeedPreferencesStatus.loaded) + .having((s) => s.feeds.length, 'feeds.length', 1) + .having((s) => s.message, 'message', 'Could not refresh feed preferences; showing cached feeds.'), + ], + ); + + blocTest( + 'loadPreferences can skip cached first emit for explicit refresh', + build: () => + FeedPreferencesCubit(feedRepository: mockFeedRepository, database: database, accountDid: 'did:plc:test'), + setUp: () async { + await database.replaceSavedFeeds('did:plc:test', [ + SavedFeedsCompanion( + id: const Value('cached-1'), + accountDid: const Value('did:plc:test'), + type: const Value('{"\$type":"app.bsky.actor.defs#savedFeedTypeKnownValue","data":"feed"}'), + value: const Value('at://did:plc:test/app.bsky.feed.generator/cached'), + pinned: const Value(true), + sortOrder: const Value(0), + updatedAt: Value(DateTime.now()), + ), + ]); + when(() => mockFeedRepository.getPreferences()).thenAnswer( + (_) async => PreferencesResult( + preferences: [ + UPreferences.savedFeedsPrefV2( + data: SavedFeedsPrefV2( + items: [ + createTestFeed( + id: 'remote-1', + value: 'at://did:plc:test/app.bsky.feed.generator/remote', + pinned: true, + ), + ], + ), + ), + ], + ), + ); + when(() => mockFeedRepository.getFeedGenerators(any())).thenAnswer((_) async => const []); + }, + act: (cubit) => cubit.loadPreferences(emitCachedFirst: false), + expect: () => [ + isA().having((s) => s.status, 'status', FeedPreferencesStatus.loading), + isA() + .having((s) => s.status, 'status', FeedPreferencesStatus.loaded) + .having((s) => s.feeds.single.id, 'feed id', 'remote-1'), ], ); -- 2.51.2