diff --git a/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart b/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart index 8856906cf..c640511e7 100644 --- a/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart +++ b/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart @@ -1140,6 +1140,7 @@ class BeeCountCloudAuthService implements CloudAuthService { String? _recoveryEmail; String? _recoveryPassword; Future? _recoveryInFlight; + bool _recoveryBlockedUntilCredentialChange = false; /// 静默恢复失败后冷却到这个时间点,期间所有 currentUser / requireAccessToken /// 调用都直接返 null,**不再发新的 /auth/login 请求**。 @@ -1174,23 +1175,19 @@ class BeeCountCloudAuthService implements CloudAuthService { return 'beecount_cloud_local_device_id_$digest'; } - Future initialize() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_sessionStorageKey); - if (raw == null || raw.isEmpty) { - return; - } + String get _logoutTombstoneStorageKey { + final raw = '$baseUrl|$apiPrefix'; + final digest = sha1.convert(utf8.encode(raw)).toString(); + return 'beecount_cloud_logout_$digest'; + } - try { - final json = jsonDecode(raw) as Map; - _session = _BeeCountCloudSession.fromJson(json); - if (_isAccessTokenExpired(_session!)) { - await _refreshSessionOrClear(); - } else { - _emitCurrentUser(); - } - } catch (_) { - await _clearSession(); + Future initialize() async { + final session = await _restorePersistedSession(); + if (session == null) return; + if (_isAccessTokenExpired(session)) { + await _refreshSessionOrClear(); + } else { + _emitCurrentUser(); } } @@ -1199,7 +1196,9 @@ class BeeCountCloudAuthService implements CloudAuthService { @override Future get currentUser async { - final session = _session; + // SharedPreferences 是同一服务地址下各 auth 实例的会话真源。每次关键 + // 认证操作先接管另一实例刚完成的登录 / token rotation / 账号切换。 + final session = await _synchronizePersistedSession(emit: true); if (session == null) { // 完全没 session(从没登过 / session 被清了):只有带了恢复凭证才尝试 // 自动重登,否则按未登录返回 null 让 UI 显示登录入口。 @@ -1209,7 +1208,7 @@ class BeeCountCloudAuthService implements CloudAuthService { final refreshed = await tryRefreshSession(); if (!refreshed) { // refresh 失败 → 凭证兜底。 - return _tryRecoveryLogin(); + return _tryRecoveryLogin(expectedEmail: session.email); } } final latest = _session; @@ -1218,7 +1217,7 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future requireAccessToken() async { - final session = _session; + final session = await _synchronizePersistedSession(emit: true); if (session == null) { final recovered = await _tryRecoveryLogin(); if (recovered == null || _session == null) { @@ -1229,7 +1228,7 @@ class BeeCountCloudAuthService implements CloudAuthService { if (_isAccessTokenExpired(session)) { final refreshed = await tryRefreshSession(); if (!refreshed || _session == null) { - final recovered = await _tryRecoveryLogin(); + final recovered = await _tryRecoveryLogin(expectedEmail: session.email); if (recovered == null || _session == null) { throw CloudNotAuthenticatedException( 'Session expired, please login again.'); @@ -1246,10 +1245,22 @@ class BeeCountCloudAuthService implements CloudAuthService { /// 失败后进 30 秒冷却期(见 [_silentRecoveryCooldownUntil] 注释): /// 防止 UI 频繁 rebuild 导致每次都 POST /auth/login,撞 server 30/min 限流, /// 让用户主动点「重新登录」时反而被 429 挡掉。 - Future _tryRecoveryLogin() async { + Future _tryRecoveryLogin({String? expectedEmail}) async { final email = _recoveryEmail; final password = _recoveryPassword; if (email == null || password == null) return null; + final prefs = await SharedPreferences.getInstance(); + if (_recoveryBlockedUntilCredentialChange || + prefs.getBool(_logoutTombstoneStorageKey) == true) { + _recoveryBlockedUntilCredentialChange = true; + return null; + } + // 旧实例可能还缓存账号 A 的邮密,但共享 session 已被另一实例切到 B。 + // B 的 refresh 失败时绝不能用 A 的旧凭证覆盖当前账号。 + if (expectedEmail != null && + email.trim().toLowerCase() != expectedEmail.trim().toLowerCase()) { + return null; + } // 冷却期内直接返 null,不打网络请求 final cooldown = _silentRecoveryCooldownUntil; @@ -1312,12 +1323,17 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future _doRefreshSession() async { + final session = await _synchronizePersistedSession(emit: true); + if (session == null) return false; + final refreshToken = session.refreshToken; try { - await _refreshSession(); - return true; + return await _refreshSession(); } catch (_) { - await _clearSession(); - return false; + // 只清除本次失败所使用的旧 rotating token。若另一实例已经刷新并 + // 持久化了新 token,则载入新 session 继续使用。 + final cleared = + await _clearSession(expectedRefreshToken: refreshToken); + return !cleared && _session != null; } } @@ -1562,10 +1578,11 @@ class BeeCountCloudAuthService implements CloudAuthService { @override Future signOut() async { - final session = _session; - if (session == null) { - return; - } + // 先原子清本地 session + 写 logout tombstone,再 best-effort 通知 server。 + // 这样在途 refresh 即使随后成功,保存响应时也会看到 tombstone 并丢弃, + // 不会在用户点击退出后把 session 复活。 + final session = await _takeSessionForLogout(); + if (session == null) return; try { await _request( @@ -1575,9 +1592,7 @@ class BeeCountCloudAuthService implements CloudAuthService { accessToken: session.accessToken, ); } catch (_) { - // Ignore network/logout errors and clear local session directly. - } finally { - await _clearSession(); + // Ignore network/logout errors; local logout already completed. } } @@ -1724,7 +1739,7 @@ class BeeCountCloudAuthService implements CloudAuthService { await tryRefreshSession(); } - Future _refreshSession() async { + Future _refreshSession() async { final session = _session; if (session == null) { throw CloudNotAuthenticatedException(); @@ -1742,16 +1757,39 @@ class BeeCountCloudAuthService implements CloudAuthService { final payload = _decodeJsonObject(response.body); final refreshed = _BeeCountCloudSession.fromAuthResponse(payload); - await _saveSession(refreshed); + return _withSessionMutation(() async { + final persisted = await _readPersistedSessionUnlocked(); + if (persisted == null) { + _session = null; + _recoveryBlockedUntilCredentialChange = true; + _authStateController.add(null); + return false; + } + if (persisted.refreshToken != session.refreshToken) { + // 请求在途时另一实例已轮换 token 或切换账号。丢弃旧响应并接管 + // 当前权威 session,不能让旧响应覆盖它。 + _session = persisted; + _emitCurrentUser(); + return true; + } + await _saveSessionUnlocked(refreshed); + return true; + }); } Future _saveSession(_BeeCountCloudSession session) async { + await _withSessionMutation(() => _saveSessionUnlocked(session)); + } + + Future _saveSessionUnlocked(_BeeCountCloudSession session) async { _session = session; + _recoveryBlockedUntilCredentialChange = false; // 任何成功登录路径都清掉静默恢复冷却,避免之前的失败状态拖到现在。 _silentRecoveryCooldownUntil = null; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_sessionStorageKey, jsonEncode(session.toJson())); await prefs.setString(_localDeviceIdStorageKey, session.deviceId); + await prefs.remove(_logoutTombstoneStorageKey); final metadata = _deviceMetadataCache; if (metadata != null && metadata.deviceId != session.deviceId) { _deviceMetadataCache = _BeeCountDeviceMetadata( @@ -1766,11 +1804,127 @@ class BeeCountCloudAuthService implements CloudAuthService { _emitCurrentUser(); } - Future _clearSession() async { - _session = null; + static final Map> _sessionMutationTails = {}; + + Future _withSessionMutation(Future Function() action) async { + final key = _sessionStorageKey; + final previous = _sessionMutationTails[key] ?? Future.value(); + final gate = Completer(); + final tail = previous.then((_) => gate.future, onError: (_) => gate.future); + _sessionMutationTails[key] = tail; + try { + await previous.catchError((Object _) {}); + return await action(); + } finally { + gate.complete(); + if (identical(_sessionMutationTails[key], tail)) { + _sessionMutationTails.remove(key); + } + } + } + + Future<_BeeCountCloudSession?> _readPersistedSessionUnlocked() async { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_sessionStorageKey); - _authStateController.add(null); + final raw = prefs.getString(_sessionStorageKey); + if (raw == null || raw.isEmpty) return null; + + try { + return _BeeCountCloudSession.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + // 会话变更都在同一把进程内锁中;这里只删除仍与读取值相同的坏数据。 + if (prefs.getString(_sessionStorageKey) == raw) { + await prefs.remove(_sessionStorageKey); + } + return null; + } + } + + Future<_BeeCountCloudSession?> _synchronizePersistedSession({ + bool emit = false, + }) { + return _withSessionMutation(() async { + final persisted = await _readPersistedSessionUnlocked(); + final current = _session; + if (persisted == null) { + final prefs = await SharedPreferences.getInstance(); + final loggedOut = prefs.getBool(_logoutTombstoneStorageKey) == true; + if (current != null) { + _session = null; + _recoveryBlockedUntilCredentialChange = loggedOut; + if (emit) _authStateController.add(null); + } else if (loggedOut) { + _recoveryBlockedUntilCredentialChange = true; + } + return null; + } + if (current == null || + persisted.userId != current.userId || + persisted.refreshToken != current.refreshToken) { + _session = persisted; + _recoveryBlockedUntilCredentialChange = false; + if (emit) _emitCurrentUser(); + return persisted; + } + return current; + }); + } + + Future<_BeeCountCloudSession?> _restorePersistedSession({ + bool emit = false, + }) { + return _withSessionMutation(() async { + final session = await _readPersistedSessionUnlocked(); + if (session == null) return null; + _session = session; + if (emit) _emitCurrentUser(); + return session; + }); + } + + /// 返回 true 表示 session 已清除;false 表示发现并载入了更新的 session。 + Future _clearSession({ + String? expectedRefreshToken, + bool blockRecovery = false, + }) { + return _withSessionMutation(() async { + final prefs = await SharedPreferences.getInstance(); + if (expectedRefreshToken != null) { + final persisted = await _readPersistedSessionUnlocked(); + if (persisted != null && + persisted.refreshToken != expectedRefreshToken) { + // 另一实例已完成 rotation 或账号切换;接管当前权威 session。 + _session = persisted; + _recoveryBlockedUntilCredentialChange = false; + _emitCurrentUser(); + return false; + } + } + + _session = null; + _recoveryBlockedUntilCredentialChange = blockRecovery; + await prefs.remove(_sessionStorageKey); + if (blockRecovery) { + await prefs.setBool(_logoutTombstoneStorageKey, true); + } + _authStateController.add(null); + return true; + }); + } + + Future<_BeeCountCloudSession?> _takeSessionForLogout() { + return _withSessionMutation(() async { + final prefs = await SharedPreferences.getInstance(); + final persisted = await _readPersistedSessionUnlocked(); + final session = persisted ?? _session; + _session = null; + _recoveryBlockedUntilCredentialChange = true; + await prefs.remove(_sessionStorageKey); + await prefs.setBool(_logoutTombstoneStorageKey, true); + _authStateController.add(null); + return session; + }); } void _emitCurrentUser() { diff --git a/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart b/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart new file mode 100644 index 000000000..ed262f784 --- /dev/null +++ b/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart @@ -0,0 +1,501 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Map _sessionPayload({ + required String accessToken, + required String refreshToken, +}) { + return { + 'user': {'id': 'user-1', 'email': 'user@example.com'}, + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_in': 3600, + 'device_id': 'device-1', + }; +} + +http.Response _jsonResponse(Map body, {int status = 200}) { + return http.Response( + jsonEncode(body), + status, + headers: {'content-type': 'application/json'}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('reloads a 2FA session persisted by another auth instance', () async { + var silentLoginRequests = 0; + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + silentLoginRequests++; + return _jsonResponse({ + 'requires_2fa': true, + 'challenge_token': 'challenge-stale', + 'available_methods': ['totp'], + }); + } + return _jsonResponse({'error': 'unexpected request'}, status: 500); + }), + ); + await stale.initialize(); + stale.setRecoveryCredentials( + email: 'user@example.com', + password: 'secret', + ); + + final interactive = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return _jsonResponse({ + 'requires_2fa': true, + 'challenge_token': 'challenge-1', + 'available_methods': ['totp'], + }); + } + if (request.url.path.endsWith('/auth/2fa/verify')) { + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + } + return _jsonResponse({'error': 'unexpected request'}, status: 500); + }), + twoFactorHandler: (challenge) async { + final error = await challenge.verify('totp', '123456'); + expect(error, isNull); + return true; + }, + ); + await interactive.initialize(); + + addTearDown(stale.dispose); + addTearDown(interactive.dispose); + + await interactive.signInWithEmail( + email: 'user@example.com', + password: 'secret', + ); + + expect(await stale.requireAccessToken(), 'access-1'); + expect(silentLoginRequests, 0, + reason: 'persisted 2FA session should win over silent password login'); + }); + + test('stale refresh failure cannot delete a newer rotated session', () async { + final seed = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse(_sessionPayload( + accessToken: 'access-0', + refreshToken: 'refresh-0', + )); + }), + ); + await seed.initialize(); + await seed.signInWithEmail( + email: 'user@example.com', + password: 'secret', + ); + + final staleRequestStarted = Completer(); + final releaseStaleRequest = Completer(); + final winner = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + expect(request.url.path, endsWith('/auth/refresh')); + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + }), + ); + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + expect(request.url.path, endsWith('/auth/refresh')); + staleRequestStarted.complete(); + await releaseStaleRequest.future; + return _jsonResponse({'error': 'refresh token revoked'}, status: 401); + }), + ); + await winner.initialize(); + await stale.initialize(); + + addTearDown(seed.dispose); + addTearDown(winner.dispose); + addTearDown(stale.dispose); + + final staleRefresh = stale.tryRefreshSession(); + await staleRequestStarted.future; + expect(await winner.tryRefreshSession(), isTrue); + releaseStaleRequest.complete(); + expect(await staleRefresh, isTrue, + reason: 'stale instance should adopt the newer persisted session'); + expect(await stale.requireAccessToken(), 'access-1'); + + final restored = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + fail('restoring a valid session must not call the server'); + }), + ); + await restored.initialize(); + addTearDown(restored.dispose); + + expect(await restored.requireAccessToken(), 'access-1'); + }); + + test('empty stale instance adopts persisted session before refresh', () async { + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + expect(request.url.path, endsWith('/auth/refresh')); + return _jsonResponse(_sessionPayload( + accessToken: 'access-2', + refreshToken: 'refresh-2', + )); + }), + ); + await stale.initialize(); + + final interactive = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + }), + ); + await interactive.initialize(); + await interactive.signInWithEmail( + email: 'user@example.com', + password: 'secret', + ); + + addTearDown(stale.dispose); + addTearDown(interactive.dispose); + + expect(await stale.tryRefreshSession(), isTrue); + expect(await stale.requireAccessToken(), 'access-2'); + }); + + test('stale instance adopts a different account', () async { + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse({ + 'user': {'id': 'user-1', 'email': 'a@example.com'}, + 'access_token': 'access-a', + 'refresh_token': 'refresh-a', + 'expires_in': 3600, + 'device_id': 'device-1', + }); + }), + ); + await stale.initialize(); + stale.setRecoveryCredentials(email: 'a@example.com', password: 'secret-a'); + await stale.signInWithEmail(email: 'a@example.com', password: 'secret-a'); + + final accountB = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse({ + 'user': {'id': 'user-2', 'email': 'b@example.com'}, + 'access_token': 'access-b', + 'refresh_token': 'refresh-b', + 'expires_in': 3600, + 'device_id': 'device-2', + }); + }), + ); + await accountB.initialize(); + await accountB.signInWithEmail(email: 'b@example.com', password: 'secret-b'); + + addTearDown(stale.dispose); + addTearDown(accountB.dispose); + + expect(await stale.requireAccessToken(), 'access-b'); + expect((await stale.currentUser)?.email, 'b@example.com'); + }); + + test('successful stale refresh cannot overwrite a newer account', () async { + final requestStarted = Completer(); + final releaseRequest = Completer(); + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return _jsonResponse({ + 'user': {'id': 'user-1', 'email': 'a@example.com'}, + 'access_token': 'access-a', + 'refresh_token': 'refresh-a', + 'expires_in': 3600, + 'device_id': 'device-1', + }); + } + requestStarted.complete(); + await releaseRequest.future; + return _jsonResponse({ + 'user': {'id': 'user-1', 'email': 'a@example.com'}, + 'access_token': 'access-a2', + 'refresh_token': 'refresh-a2', + 'expires_in': 3600, + 'device_id': 'device-1', + }); + }), + ); + await stale.initialize(); + await stale.signInWithEmail(email: 'a@example.com', password: 'secret-a'); + + final refresh = stale.tryRefreshSession(); + await requestStarted.future; + + final accountB = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse({ + 'user': {'id': 'user-2', 'email': 'b@example.com'}, + 'access_token': 'access-b', + 'refresh_token': 'refresh-b', + 'expires_in': 3600, + 'device_id': 'device-2', + }); + }), + ); + await accountB.initialize(); + await accountB.signInWithEmail(email: 'b@example.com', password: 'secret-b'); + releaseRequest.complete(); + + addTearDown(stale.dispose); + addTearDown(accountB.dispose); + + expect(await refresh, isTrue); + expect(await stale.requireAccessToken(), 'access-b'); + }); + + test('logout clears a stale instance session', () async { + final owner = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + } + expect(request.url.path, endsWith('/auth/logout')); + return _jsonResponse({}); + }), + ); + await owner.initialize(); + await owner.signInWithEmail(email: 'user@example.com', password: 'secret'); + + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + fail('stale instance must not call the server after logout'); + }), + ); + await stale.initialize(); + await owner.signOut(); + + addTearDown(owner.dispose); + addTearDown(stale.dispose); + + await expectLater( + stale.requireAccessToken(), + throwsA(isA()), + ); + }); + + test('stale instance signs out the newest rotated session', () async { + final seed = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse(_sessionPayload( + accessToken: 'access-0', + refreshToken: 'refresh-0', + )); + }), + ); + await seed.initialize(); + await seed.signInWithEmail(email: 'user@example.com', password: 'secret'); + + String? loggedOutRefreshToken; + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + final body = jsonDecode(request.body) as Map; + loggedOutRefreshToken = body['refresh_token'] as String?; + return _jsonResponse({}); + }), + ); + final winner = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + }), + ); + await stale.initialize(); + await winner.initialize(); + await winner.tryRefreshSession(); + + addTearDown(seed.dispose); + addTearDown(stale.dispose); + addTearDown(winner.dispose); + + await stale.signOut(); + expect(loggedOutRefreshToken, 'refresh-1'); + await expectLater( + winner.requireAccessToken(), + throwsA(isA()), + ); + }); + + test('logout tombstone survives provider rebuild and blocks silent recovery', + () async { + final owner = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + } + return _jsonResponse({}); + }), + ); + await owner.initialize(); + await owner.signInWithEmail(email: 'user@example.com', password: 'secret'); + await owner.signOut(); + + var silentLoginRequests = 0; + final rebuilt = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + silentLoginRequests++; + return _jsonResponse(_sessionPayload( + accessToken: 'unexpected', + refreshToken: 'unexpected', + )); + }), + ); + await rebuilt.initialize(); + rebuilt.setRecoveryCredentials( + email: 'user@example.com', + password: 'secret', + ); + + addTearDown(owner.dispose); + addTearDown(rebuilt.dispose); + + expect(await rebuilt.currentUser, isNull); + expect(silentLoginRequests, 0); + + await rebuilt.signInWithEmail(email: 'user@example.com', password: 'secret'); + expect(await rebuilt.requireAccessToken(), 'unexpected'); + expect(silentLoginRequests, 1); + }); + + test('refresh completing during logout cannot resurrect the session', + () async { + final seed = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse(_sessionPayload( + accessToken: 'access-0', + refreshToken: 'refresh-0', + )); + }), + ); + await seed.initialize(); + await seed.signInWithEmail(email: 'user@example.com', password: 'secret'); + + final refreshStarted = Completer(); + final releaseRefresh = Completer(); + final refresher = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + refreshStarted.complete(); + await releaseRefresh.future; + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + }), + ); + await refresher.initialize(); + + final logoutStarted = Completer(); + final releaseLogout = Completer(); + final logout = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + logoutStarted.complete(); + await releaseLogout.future; + return _jsonResponse({}); + }), + ); + await logout.initialize(); + + addTearDown(seed.dispose); + addTearDown(refresher.dispose); + addTearDown(logout.dispose); + + final refresh = refresher.tryRefreshSession(); + await refreshStarted.future; + final signOut = logout.signOut(); + await logoutStarted.future; + releaseRefresh.complete(); + expect(await refresh, isFalse); + releaseLogout.complete(); + await signOut; + + await expectLater( + refresher.requireAccessToken(), + throwsA(isA()), + ); + }); +}