Use direct profile sync for account bridge setup

This commit is contained in:
Haitao Pan 2026-04-13 19:28:53 +08:00
parent 5d992f884a
commit 77fb493edd
8 changed files with 294 additions and 422 deletions

View File

@ -49,48 +49,6 @@ import 'app_controller_desktop_runtime_helpers.dart';
extension AppControllerDesktopGateway on AppController {
Future<String> 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;
}

View File

@ -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) {

View File

@ -156,45 +156,19 @@ class MobileShellStateInternal extends State<MobileShell> {
}
Future<void> 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<String>(
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<MobileShell> {
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);

View File

@ -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<String> scopes;
final bool oneTime;
final String qrPayload;
static String _stringValueStatic(Object? raw) {
return raw == null ? '' : raw.toString().trim();
}
factory BridgeBootstrapIssue.fromJson(Map<String, dynamic> json) {
List<String> scopes = const <String>[];
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<String, dynamic> 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<BridgeBootstrapIssue> createBridgeBootstrapTicket({
Future<Map<String, dynamic>> loadXWorkmateProfileSync({
required String token,
}) async {
final payload = await _requestJson(
method: 'POST',
path: '/api/auth/xworkmate/bridge/bootstrap',
bearerToken: token,
body: const <String, Object?>{},
);
return BridgeBootstrapIssue.fromJson(payload);
}
Future<BridgeBootstrapIssue> 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<BridgeBootstrapConsumeResult> consumeBridgeBootstrapTicket({
required String ticket,
required String bridgeOrigin,
}) async {
final payload = await _requestJson(
method: 'POST',
path: '/bridge/bootstrap/consume',
body: <String, Object?>{
'ticket': ticket.trim(),
'bridge': bridgeOrigin.trim(),
},
);
return BridgeBootstrapConsumeResult.fromJson(payload);
}
Future<String> readVaultSecretValue({

View File

@ -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';

View File

@ -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<String, dynamic>;
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() ?? '';

View File

@ -145,8 +145,6 @@ Future<void> completeAccountSignInSettingsInternal(
await syncAccountSettingsInternal(
controller,
baseUrl: baseUrl,
bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload),
bridgeServerUrlOverride: _resolveBridgeServerUrl(payload),
profilePayloadOverride: payload,
quiet: true,
);
@ -181,7 +179,9 @@ Future<void> 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<AccountSyncResult> syncAccountSettingsInternal(
SettingsController controller, {
String baseUrl = '',
bool quiet = false,
String bridgeTokenOverride = '',
String bridgeServerUrlOverride = '',
Map<String, dynamic> profilePayloadOverride = const <String, dynamic>{},
}) async {
final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal(
@ -252,17 +250,17 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
}
try {
if (normalizedBaseUrl.isEmpty) {
return _persistAccountSyncContractFailureInternal(
controller,
message: 'Account base URL is required',
quiet: quiet,
);
}
final client = controller.buildAccountClient(normalizedBaseUrl);
Map<String, dynamic> 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<AccountSyncResult> 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<AccountSyncResult> 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<AccountSyncResult> 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<AccountSyncResult> _persistAccountSyncFailureInternal(
return AccountSyncResult(state: state, message: message);
}
String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
final explicit = _stringValue(payload['BRIDGE_AUTH_TOKEN']);
return explicit;
Future<AccountSyncResult> _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<String, dynamic> payload) {

View File

@ -36,7 +36,8 @@ void main() {
final client = _FakeAccountRuntimeClient(
loginPayload: const <String, dynamic>{},
profilePayload: const <String, dynamic>{},
sessionPayload: const <String, dynamic>{},
syncPayload: const <String, dynamic>{},
);
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: <String, dynamic>{
'token': 'session-token',
'user': <String, dynamic>{
'id': 'user-1',
'email': 'review@svc.plus',
},
},
syncPayload: const <String, dynamic>{
'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: <String, dynamic>{
'token': 'session-token',
'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token',
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus',
'user': <String, dynamic>{
'id': 'user-1',
'email': 'review@svc.plus',
},
},
syncPayload: const <String, dynamic>{},
),
);
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 <String, dynamic>{},
profilePayload: const <String, dynamic>{},
);
final controller = SettingsController(
store,
accountClientFactory: (_) => client,
);
addTearDown(controller.dispose);
await controller.initialize();
final client = _FakeAccountRuntimeClient(
loginPayload: const <String, dynamic>{},
sessionPayload: const <String, dynamic>{},
syncPayload: const <String, dynamic>{},
);
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 <String, dynamic>{},
sessionPayload: const <String, dynamic>{
'user': <String, dynamic>{
'id': 'user-1',
'email': 'review@svc.plus',
},
},
syncPayload: <String, dynamic>{
'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 <String, dynamic>{},
profilePayload: <String, dynamic>{
'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token',
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-new.svc.plus',
sessionPayload: const <String, dynamic>{
'user': <String, dynamic>{
'id': 'user-1',
'email': 'review@svc.plus',
},
},
syncPayload: const <String, dynamic>{
'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 <String, dynamic>{},
})
: super(baseUrl: 'https://accounts.svc.plus');
this.sessionPayload = const <String, dynamic>{},
this.syncPayload = const <String, dynamic>{},
}) : super(baseUrl: 'https://accounts.svc.plus');
final Map<String, dynamic> loginPayload;
final Map<String, dynamic> profilePayload;
final Map<String, dynamic> sessionPayload;
final Map<String, dynamic> syncPayload;
int loadProfileCallCount = 0;
int loadXWorkmateProfileSyncCallCount = 0;
@override
Future<Map<String, dynamic>> login({
@ -405,6 +491,14 @@ class _FakeAccountRuntimeClient extends AccountRuntimeClient {
@override
Future<Map<String, dynamic>> loadProfile({required String token}) async {
loadProfileCallCount += 1;
return profilePayload;
return sessionPayload;
}
@override
Future<Map<String, dynamic>> loadXWorkmateProfileSync({
required String token,
}) async {
loadXWorkmateProfileSyncCallCount += 1;
return syncPayload;
}
}