Use direct profile sync for account bridge setup
This commit is contained in:
parent
5d992f884a
commit
77fb493edd
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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() ?? '';
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user