From 77fb493edde5081250b915b391f40cebdb9ffc48 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 13 Apr 2026 19:28:53 +0800 Subject: [PATCH] Use direct profile sync for account bridge setup --- lib/app/app_controller_desktop_gateway.dart | 42 -- .../mobile_gateway_pairing_guide_page.dart | 7 +- lib/features/mobile/mobile_shell_core.dart | 36 +- lib/runtime/account_runtime_client.dart | 124 +----- lib/runtime/gateway_runtime.dart | 1 - lib/runtime/gateway_runtime_bootstrap.dart | 43 -- ...ime_controllers_settings_account_impl.dart | 77 ++-- ...ime_controllers_settings_account_test.dart | 386 +++++++++++------- 8 files changed, 294 insertions(+), 422 deletions(-) delete mode 100644 lib/runtime/gateway_runtime_bootstrap.dart diff --git a/lib/app/app_controller_desktop_gateway.dart b/lib/app/app_controller_desktop_gateway.dart index 0682be39..a0fb5e0b 100644 --- a/lib/app/app_controller_desktop_gateway.dart +++ b/lib/app/app_controller_desktop_gateway.dart @@ -49,48 +49,6 @@ import 'app_controller_desktop_runtime_helpers.dart'; extension AppControllerDesktopGateway on AppController { Future resolveConnectSetupCode(String rawInput) async { final trimmed = rawInput.trim(); - if (trimmed.isEmpty) { - return trimmed; - } - if (decodeGatewaySetupCode(trimmed) != null) { - return trimmed; - } - final bootstrapEnvelope = decodeBridgeBootstrapEnvelope(trimmed); - if (bootstrapEnvelope != null) { - final bridgeClient = AccountRuntimeClient( - baseUrl: bootstrapEnvelope.bridgeOrigin, - ); - final consumed = await bridgeClient.consumeBridgeBootstrapTicket( - ticket: bootstrapEnvelope.ticket, - bridgeOrigin: bootstrapEnvelope.bridgeOrigin, - ); - return consumed.setupCode.trim(); - } - if (isBridgeBootstrapShortCode(trimmed)) { - final sessionToken = - (await storeInternal.loadAccountSessionToken())?.trim() ?? ''; - final accountBaseUrl = settings.accountBaseUrl.trim().isNotEmpty - ? settings.accountBaseUrl.trim() - : settingsControllerInternal.snapshot.accountBaseUrl.trim(); - if (sessionToken.isEmpty || accountBaseUrl.isEmpty) { - throw StateError( - 'Account sign-in is required before using a bridge verification code.', - ); - } - final accountClient = settingsControllerInternal.buildAccountClient( - accountBaseUrl, - ); - final issue = await accountClient.lookupBridgeBootstrapTicket( - token: sessionToken, - shortCode: trimmed, - ); - final bridgeClient = AccountRuntimeClient(baseUrl: issue.bridgeOrigin); - final consumed = await bridgeClient.consumeBridgeBootstrapTicket( - ticket: issue.ticket, - bridgeOrigin: issue.bridgeOrigin, - ); - return consumed.setupCode.trim(); - } return trimmed; } diff --git a/lib/features/mobile/mobile_gateway_pairing_guide_page.dart b/lib/features/mobile/mobile_gateway_pairing_guide_page.dart index f28fbd0b..4f14fb8a 100644 --- a/lib/features/mobile/mobile_gateway_pairing_guide_page.dart +++ b/lib/features/mobile/mobile_gateway_pairing_guide_page.dart @@ -214,7 +214,7 @@ class MobileGatewayPairingGuidePage extends StatelessWidget { ), ), child: Text( - '输入验证码', + '输入配置码', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), @@ -367,10 +367,7 @@ String? resolveGatewaySetupCodeFromScan(String raw) { if (decodeGatewaySetupCode(candidate) != null) { return candidate; } - if (decodeBridgeBootstrapEnvelope(candidate) != null) { - return candidate; - } - return isBridgeBootstrapShortCode(candidate) ? candidate : null; + return null; } String? _extractSetupCodeFromJsonPayload(String raw) { diff --git a/lib/features/mobile/mobile_shell_core.dart b/lib/features/mobile/mobile_shell_core.dart index ffd5d5db..b19ee968 100644 --- a/lib/features/mobile/mobile_shell_core.dart +++ b/lib/features/mobile/mobile_shell_core.dart @@ -156,45 +156,19 @@ class MobileShellStateInternal extends State { } Future promptBridgeVerificationCodeInternal() async { - final accountSignedIn = - (await widget.controller.storeInternal.loadAccountSessionToken()) - ?.trim() - .isNotEmpty ?? - false; - if (!mounted) { - return; - } - if (!accountSignedIn) { - await openGatewaySetupCodeEntryInternal(); - if (!mounted) { - return; - } - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar( - content: Text( - appText( - '未登录账号时,请先手动输入配置码。登录 accounts.svc.plus 后可使用验证码接入。', - 'When account sign-in is unavailable, enter a setup code manually. Sign in to accounts.svc.plus first to use bridge verification codes.', - ), - ), - ), - ); - return; - } final codeController = TextEditingController(); final enteredCode = await showDialog( context: context, builder: (dialogContext) { return AlertDialog( - title: Text(appText('输入验证码', 'Enter Verification Code')), + title: Text(appText('输入配置码', 'Enter Setup Code')), content: TextField( controller: codeController, autofocus: true, textCapitalization: TextCapitalization.characters, decoration: InputDecoration( - labelText: appText('验证码', 'Verification Code'), - hintText: 'AB12CD34', + labelText: appText('配置码', 'Setup Code'), + hintText: appText('粘贴配置码', 'Paste setup code'), ), ), actions: [ @@ -290,7 +264,9 @@ class MobileShellStateInternal extends State { if (features.isEnabledPath(UiFeatureKeys.navigationSettings)) MobileShellTab.settings, ]; - final currentTab = tabForDestinationInternal(widget.controller.destination); + final currentTab = tabForDestinationInternal( + widget.controller.destination, + ); final resolvedCurrentTab = availableTabs.contains(currentTab) ? currentTab : (availableTabs.isEmpty ? currentTab : availableTabs.first); diff --git a/lib/runtime/account_runtime_client.dart b/lib/runtime/account_runtime_client.dart index d70d6623..cf1dd6bf 100644 --- a/lib/runtime/account_runtime_client.dart +++ b/lib/runtime/account_runtime_client.dart @@ -20,92 +20,6 @@ class AccountRuntimeException implements Exception { } } -class BridgeBootstrapIssue { - const BridgeBootstrapIssue({ - required this.ticket, - required this.shortCode, - required this.bridgeOrigin, - required this.scheme, - required this.expiresAt, - required this.scopes, - required this.oneTime, - required this.qrPayload, - }); - - final String ticket; - final String shortCode; - final String bridgeOrigin; - final String scheme; - final String expiresAt; - final List scopes; - final bool oneTime; - final String qrPayload; - - static String _stringValueStatic(Object? raw) { - return raw == null ? '' : raw.toString().trim(); - } - - factory BridgeBootstrapIssue.fromJson(Map json) { - List scopes = const []; - if (json['scopes'] is List) { - scopes = (json['scopes'] as List) - .map((item) => item.toString().trim()) - .where((item) => item.isNotEmpty) - .toList(growable: false); - } - return BridgeBootstrapIssue( - ticket: BridgeBootstrapIssue._stringValueStatic(json['ticket']), - shortCode: BridgeBootstrapIssue._stringValueStatic(json['shortCode']), - bridgeOrigin: BridgeBootstrapIssue._stringValueStatic(json['bridge']), - scheme: BridgeBootstrapIssue._stringValueStatic(json['scheme']), - expiresAt: BridgeBootstrapIssue._stringValueStatic(json['expiresAt']), - scopes: scopes, - oneTime: json['oneTime'] as bool? ?? false, - qrPayload: BridgeBootstrapIssue._stringValueStatic(json['qrPayload']), - ); - } -} - -class BridgeBootstrapConsumeResult { - const BridgeBootstrapConsumeResult({ - required this.setupCode, - required this.bridgeOrigin, - required this.authMode, - required this.expiresAt, - required this.issuedBy, - }); - - final String setupCode; - final String bridgeOrigin; - final String authMode; - final String expiresAt; - final String issuedBy; - - static String _stringValueStatic(Object? raw) { - return raw == null ? '' : raw.toString().trim(); - } - - factory BridgeBootstrapConsumeResult.fromJson(Map json) { - return BridgeBootstrapConsumeResult( - setupCode: BridgeBootstrapConsumeResult._stringValueStatic( - json['setupCode'], - ), - bridgeOrigin: BridgeBootstrapConsumeResult._stringValueStatic( - json['bridgeOrigin'], - ), - authMode: BridgeBootstrapConsumeResult._stringValueStatic( - json['authMode'], - ), - expiresAt: BridgeBootstrapConsumeResult._stringValueStatic( - json['expiresAt'], - ), - issuedBy: BridgeBootstrapConsumeResult._stringValueStatic( - json['issuedBy'], - ), - ); - } -} - class AccountRuntimeClient { AccountRuntimeClient({required String baseUrl}) : baseUrl = _normalizeBaseUrl(baseUrl); @@ -166,44 +80,14 @@ class AccountRuntimeClient { ); } - Future createBridgeBootstrapTicket({ + Future> loadXWorkmateProfileSync({ required String token, - }) async { - final payload = await _requestJson( - method: 'POST', - path: '/api/auth/xworkmate/bridge/bootstrap', - bearerToken: token, - body: const {}, - ); - return BridgeBootstrapIssue.fromJson(payload); - } - - Future lookupBridgeBootstrapTicket({ - required String token, - required String shortCode, - }) async { - final payload = await _requestJson( + }) { + return _requestJson( method: 'GET', - path: - '/api/auth/xworkmate/bridge/bootstrap/${Uri.encodeComponent(shortCode.trim())}', + path: '/api/auth/xworkmate/profile/sync', bearerToken: token, ); - return BridgeBootstrapIssue.fromJson(payload); - } - - Future consumeBridgeBootstrapTicket({ - required String ticket, - required String bridgeOrigin, - }) async { - final payload = await _requestJson( - method: 'POST', - path: '/bridge/bootstrap/consume', - body: { - 'ticket': ticket.trim(), - 'bridge': bridgeOrigin.trim(), - }, - ); - return BridgeBootstrapConsumeResult.fromJson(payload); } Future readVaultSecretValue({ diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index c51a8499..e6d934e5 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -1,6 +1,5 @@ export 'gateway_runtime_protocol.dart'; export 'gateway_runtime_events.dart'; export 'gateway_runtime_errors.dart'; -export 'gateway_runtime_bootstrap.dart'; export 'gateway_runtime_helpers.dart'; export 'gateway_runtime_core.dart'; diff --git a/lib/runtime/gateway_runtime_bootstrap.dart b/lib/runtime/gateway_runtime_bootstrap.dart deleted file mode 100644 index 59602a73..00000000 --- a/lib/runtime/gateway_runtime_bootstrap.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'dart:convert'; - -class BridgeBootstrapEnvelope { - const BridgeBootstrapEnvelope({ - required this.ticket, - required this.bridgeOrigin, - }); - - final String ticket; - final String bridgeOrigin; -} - -BridgeBootstrapEnvelope? decodeBridgeBootstrapEnvelope(String rawInput) { - final trimmed = rawInput.trim(); - if (trimmed.isEmpty || !trimmed.startsWith('{')) { - return null; - } - try { - final json = jsonDecode(trimmed) as Map; - final scheme = _stringValue(json['scheme']); - if (scheme.trim() != 'xworkmate-bridge-bootstrap') { - return null; - } - final ticket = _stringValue(json['ticket']); - final bridge = _stringValue(json['bridge']); - if (ticket.trim().isEmpty || bridge.trim().isEmpty) { - return null; - } - return BridgeBootstrapEnvelope( - ticket: ticket.trim(), - bridgeOrigin: bridge.trim(), - ); - } catch (_) { - return null; - } -} - -bool isBridgeBootstrapShortCode(String rawInput) { - final trimmed = rawInput.trim(); - return RegExp(r'^[A-Z0-9]{6,8}$', caseSensitive: false).hasMatch(trimmed); -} - -String _stringValue(Object? value) => value?.toString().trim() ?? ''; diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 4d41b427..799e205b 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -145,8 +145,6 @@ Future completeAccountSignInSettingsInternal( await syncAccountSettingsInternal( controller, baseUrl: baseUrl, - bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload), - bridgeServerUrlOverride: _resolveBridgeServerUrl(payload), profilePayloadOverride: payload, quiet: true, ); @@ -181,7 +179,9 @@ Future restoreAccountSessionSettingsInternal( try { final client = controller.buildAccountClient(normalizedBaseUrl); final payload = await client.loadProfile(token: token); - final session = _accountSessionSummaryFromUserPayload(_asMap(payload['user'])); + final session = _accountSessionSummaryFromUserPayload( + _asMap(payload['user']), + ); await controller.storeInternal.saveAccountSessionSummary(session); if (session.userId.trim().isNotEmpty) { await controller.storeInternal.saveAccountSessionUserId(session.userId); @@ -226,8 +226,6 @@ Future syncAccountSettingsInternal( SettingsController controller, { String baseUrl = '', bool quiet = false, - String bridgeTokenOverride = '', - String bridgeServerUrlOverride = '', Map profilePayloadOverride = const {}, }) async { final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( @@ -252,17 +250,17 @@ Future syncAccountSettingsInternal( } try { + if (normalizedBaseUrl.isEmpty) { + return _persistAccountSyncContractFailureInternal( + controller, + message: 'Account base URL is required', + quiet: quiet, + ); + } + + final client = controller.buildAccountClient(normalizedBaseUrl); Map profilePayload = profilePayloadOverride; if (profilePayload.isEmpty) { - if (normalizedBaseUrl.isEmpty) { - return _persistAccountSyncFailureInternal( - controller, - state: 'blocked', - message: 'Account base URL is required', - quiet: quiet, - ); - } - final client = controller.buildAccountClient(normalizedBaseUrl); profilePayload = await client.loadProfile(token: sessionToken); } await _persistAccountSessionSummaryFromProfilePayloadInternal( @@ -270,19 +268,13 @@ Future syncAccountSettingsInternal( profilePayload, ); - final profileBridgeToken = _resolveBridgeAuthorizationToken(profilePayload); - final bridgeToken = bridgeTokenOverride.trim().isNotEmpty - ? bridgeTokenOverride.trim() - : profileBridgeToken.trim().isNotEmpty - ? profileBridgeToken.trim() - : ((await controller.storeInternal.loadAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - ))?.trim() ?? - ''); + final syncPayload = await client.loadXWorkmateProfileSync( + token: sessionToken, + ); + final bridgeToken = _stringValue(syncPayload['BRIDGE_AUTH_TOKEN']); if (bridgeToken.isEmpty) { - return _persistAccountSyncFailureInternal( + return _persistAccountSyncContractFailureInternal( controller, - state: 'blocked', message: 'Bridge authorization is unavailable', quiet: quiet, ); @@ -292,11 +284,17 @@ Future syncAccountSettingsInternal( target: kAccountManagedSecretTargetBridgeAuthToken, value: bridgeToken, ); + final syncedBridgeServerUrl = _resolveBridgeServerUrl(syncPayload); + if (!isSupportedExternalAcpEndpoint(syncedBridgeServerUrl)) { + return _persistAccountSyncContractFailureInternal( + controller, + message: 'Bridge endpoint is unavailable', + quiet: quiet, + ); + } final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl( controller, - bridgeServerUrlOverride: bridgeServerUrlOverride.trim().isNotEmpty - ? bridgeServerUrlOverride - : _resolveBridgeServerUrl(profilePayload), + bridgeServerUrlOverride: syncedBridgeServerUrl, ); await controller.storeInternal.clearAccountManagedSecret( target: kAccountManagedSecretTargetAIGatewayAccessToken, @@ -356,16 +354,14 @@ Future syncAccountSettingsInternal( message: 'Bridge access synced', ); } on AccountRuntimeException catch (error) { - return _persistAccountSyncFailureInternal( + return _persistAccountSyncContractFailureInternal( controller, - state: 'error', message: error.message, quiet: quiet, ); } catch (error) { - return _persistAccountSyncFailureInternal( + return _persistAccountSyncContractFailureInternal( controller, - state: 'error', message: error.toString(), quiet: quiet, ); @@ -532,9 +528,20 @@ Future _persistAccountSyncFailureInternal( return AccountSyncResult(state: state, message: message); } -String _resolveBridgeAuthorizationToken(Map payload) { - final explicit = _stringValue(payload['BRIDGE_AUTH_TOKEN']); - return explicit; +Future _persistAccountSyncContractFailureInternal( + SettingsController controller, { + required String message, + required bool quiet, +}) async { + await controller.storeInternal.clearAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ); + return _persistAccountSyncFailureInternal( + controller, + state: 'blocked', + message: message, + quiet: quiet, + ); } String _resolveBridgeServerUrl(Map payload) { diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index e1ffb7f0..b4dcdd6b 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -36,7 +36,8 @@ void main() { final client = _FakeAccountRuntimeClient( loginPayload: const {}, - profilePayload: const {}, + sessionPayload: const {}, + syncPayload: const {}, ); final controller = SettingsController( store, @@ -64,10 +65,13 @@ void main() { ); expect(controller.accountStatus, 'Bridge authorization is unavailable'); expect(client.loadProfileCallCount, 1); + expect(client.loadXWorkmateProfileSyncCallCount, 1); }, ); - test('login sync stores BRIDGE_AUTH_TOKEN from login payload', () async { + test( + 'login sync stores managed bridge contract from protected profile sync', + () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-account-sync-uppercase-token-', ); @@ -90,6 +94,80 @@ void main() { ), ); + final controller = SettingsController( + store, + accountClientFactory: (_) => _FakeAccountRuntimeClient( + loginPayload: { + 'token': 'session-token', + 'user': { + 'id': 'user-1', + 'email': 'review@svc.plus', + }, + }, + syncPayload: const { + 'BRIDGE_AUTH_TOKEN': 'bridge-token-from-sync', + 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus', + }, + ), + ); + addTearDown(controller.dispose); + await controller.initialize(); + + await controller.loginAccount( + baseUrl: 'https://accounts.svc.plus', + identifier: 'review@svc.plus', + password: 'password', + ); + + expect(controller.accountSyncState, isNotNull); + expect(controller.accountSyncState!.syncState, 'ready'); + expect( + controller.accountSyncState!.syncedDefaults.bridgeServerUrl, + 'https://xworkmate-bridge-alt.svc.plus', + ); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .endpoint, + 'https://xworkmate-bridge-alt.svc.plus', + ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + 'bridge-token-from-sync', + ); + }, + ); + + test( + 'login sync ignores bridge token fields outside protected profile sync', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-sync-legacy-token-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + ), + ); + final controller = SettingsController( store, accountClientFactory: (_) => _FakeAccountRuntimeClient( @@ -102,78 +180,7 @@ void main() { 'email': 'review@svc.plus', }, }, - ), - ); - addTearDown(controller.dispose); - await controller.initialize(); - - await controller.loginAccount( - baseUrl: 'https://accounts.svc.plus', - identifier: 'review@svc.plus', - password: 'password', - ); - - expect(controller.accountSyncState, isNotNull); - expect(controller.accountSyncState!.syncState, 'ready'); - expect( - controller.accountSyncState!.syncedDefaults.bridgeServerUrl, - 'https://xworkmate-bridge-alt.svc.plus', - ); - expect( - controller - .snapshot - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint, - 'https://xworkmate-bridge-alt.svc.plus', - ); - expect( - await store.loadAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - ), - 'bridge-token-from-login', - ); - }, - ); - - test( - 'login sync ignores legacy INTERNAL_SERVICE_TOKEN fallback', - () async { - final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-account-sync-legacy-token-', - ); - addTearDown(() async { - if (await storeRoot.exists()) { - await storeRoot.delete(recursive: true); - } - }); - - final store = SecureConfigStore( - secretRootPathResolver: () async => '${storeRoot.path}/secrets', - appDataRootPathResolver: () async => '${storeRoot.path}/app-data', - supportRootPathResolver: () async => '${storeRoot.path}/support', - enableSecureStorage: false, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - SettingsSnapshot.defaults().copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - ), - ); - - final controller = SettingsController( - store, - accountClientFactory: (_) => _FakeAccountRuntimeClient( - loginPayload: { - 'token': 'session-token', - 'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token', - 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus', - 'user': { - 'id': 'user-1', - 'email': 'review@svc.plus', - }, - }, + syncPayload: const {}, ), ); addTearDown(controller.dispose); @@ -196,67 +203,71 @@ void main() { }, ); - test('syncAccountSettings pins the managed bridge cloud entry', () async { - final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-account-managed-bridge-', - ); - addTearDown(() async { - if (await storeRoot.exists()) { - await storeRoot.delete(recursive: true); - } - }); + test( + 'syncAccountSettings does not recover from stale managed bridge token', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-managed-bridge-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); - final store = SecureConfigStore( - secretRootPathResolver: () async => '${storeRoot.path}/secrets', - appDataRootPathResolver: () async => '${storeRoot.path}/app-data', - supportRootPathResolver: () async => '${storeRoot.path}/support', - enableSecureStorage: false, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - SettingsSnapshot.defaults().copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - accountUsername: 'review@svc.plus', - ), - ); - await store.saveAccountSessionToken('session-token'); - await store.saveAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - value: 'bridge-token', - ); + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + assistantExecutionTarget: AssistantExecutionTarget.gateway, + ), + ); + await store.saveAccountSessionToken('session-token'); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); - final client = _FakeAccountRuntimeClient( - loginPayload: const {}, - profilePayload: const {}, - ); - final controller = SettingsController( - store, - accountClientFactory: (_) => client, - ); - addTearDown(controller.dispose); - await controller.initialize(); + final client = _FakeAccountRuntimeClient( + loginPayload: const {}, + sessionPayload: const {}, + syncPayload: const {}, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); + addTearDown(controller.dispose); + await controller.initialize(); - final result = await controller.syncAccountSettings( - baseUrl: 'https://accounts.svc.plus', - ); + final result = await controller.syncAccountSettings( + baseUrl: 'https://accounts.svc.plus', + ); - expect(result.state, 'ready'); - expect(controller.accountSyncState, isNotNull); - expect( - controller.accountSyncState!.syncedDefaults.bridgeServerUrl, - kManagedBridgeServerUrl, - ); - expect( - controller - .snapshot - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint, - kManagedBridgeServerUrl, - ); - expect(client.loadProfileCallCount, 1); - }); + expect(result.state, 'blocked'); + expect(controller.accountSyncState, isNotNull); + expect(controller.accountSyncState!.syncState, 'blocked'); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + isNull, + ); + expect( + controller.accountSyncState!.syncMessage, + 'Bridge authorization is unavailable', + ); + expect(client.loadProfileCallCount, 1); + expect(client.loadXWorkmateProfileSyncCallCount, 1); + }, + ); test( 'syncAccountSettings refreshes managed bridge contract from protected account profile', @@ -270,6 +281,82 @@ void main() { } }); + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + assistantExecutionTarget: AssistantExecutionTarget.gateway, + ), + ); + await store.saveAccountSessionToken('session-token'); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'stale-bridge-token', + ); + + final client = _FakeAccountRuntimeClient( + loginPayload: const {}, + sessionPayload: const { + 'user': { + 'id': 'user-1', + 'email': 'review@svc.plus', + }, + }, + syncPayload: { + 'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token', + 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-new.svc.plus', + }, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); + addTearDown(controller.dispose); + await controller.initialize(); + + final result = await controller.syncAccountSettings( + baseUrl: 'https://accounts.svc.plus', + ); + + expect(result.state, 'ready'); + expect(client.loadProfileCallCount, 1); + expect(client.loadXWorkmateProfileSyncCallCount, 1); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + 'fresh-bridge-token', + ); + expect( + controller.accountSyncState!.syncedDefaults.bridgeServerUrl, + 'https://xworkmate-bridge-new.svc.plus', + ); + expect( + controller.snapshot.assistantExecutionTarget, + AssistantExecutionTarget.gateway, + ); + }, + ); + + test( + 'syncAccountSettings blocks and clears stale token when bridge endpoint is unavailable', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-managed-bridge-missing-url-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + final store = SecureConfigStore( secretRootPathResolver: () async => '${storeRoot.path}/secrets', appDataRootPathResolver: () async => '${storeRoot.path}/app-data', @@ -291,14 +378,15 @@ void main() { final client = _FakeAccountRuntimeClient( loginPayload: const {}, - profilePayload: { - 'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token', - 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-new.svc.plus', + sessionPayload: const { 'user': { 'id': 'user-1', 'email': 'review@svc.plus', }, }, + syncPayload: const { + 'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token', + }, ); final controller = SettingsController( store, @@ -311,17 +399,13 @@ void main() { baseUrl: 'https://accounts.svc.plus', ); - expect(result.state, 'ready'); - expect(client.loadProfileCallCount, 1); + expect(result.state, 'blocked'); + expect(result.message, 'Bridge endpoint is unavailable'); expect( await store.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ), - 'fresh-bridge-token', - ); - expect( - controller.accountSyncState!.syncedDefaults.bridgeServerUrl, - 'https://xworkmate-bridge-new.svc.plus', + isNull, ); }, ); @@ -386,13 +470,15 @@ void main() { class _FakeAccountRuntimeClient extends AccountRuntimeClient { _FakeAccountRuntimeClient({ required this.loginPayload, - this.profilePayload = const {}, - }) - : super(baseUrl: 'https://accounts.svc.plus'); + this.sessionPayload = const {}, + this.syncPayload = const {}, + }) : super(baseUrl: 'https://accounts.svc.plus'); final Map loginPayload; - final Map profilePayload; + final Map sessionPayload; + final Map syncPayload; int loadProfileCallCount = 0; + int loadXWorkmateProfileSyncCallCount = 0; @override Future> login({ @@ -405,6 +491,14 @@ class _FakeAccountRuntimeClient extends AccountRuntimeClient { @override Future> loadProfile({required String token}) async { loadProfileCallCount += 1; - return profilePayload; + return sessionPayload; + } + + @override + Future> loadXWorkmateProfileSync({ + required String token, + }) async { + loadXWorkmateProfileSyncCallCount += 1; + return syncPayload; } }