fix: repair bridge login sync runtime state
This commit is contained in:
parent
e7a7ba92c4
commit
bdb9faeff5
@ -29,8 +29,9 @@ Last Updated: 2026-04-22
|
||||
两份脚本都依赖以下环境变量:
|
||||
|
||||
- `REVIEW_ACCOUNT_LOGIN_PASSWORD`
|
||||
- `BRIDGE_AUTH_TOKEN`
|
||||
- 可选 `BRIDGE_AUTH_TOKEN`,未提供时使用 profile sync 返回的 token
|
||||
- 可选 `BRIDGE_SERVER_URL`
|
||||
- 可选 `BRIDGE_SERVER_URLS`,用于接口脚本同时验证多个 bridge host
|
||||
- 可选 `REVIEW_ACCOUNT_BASE_URL`
|
||||
|
||||
推荐直接在命令前临时注入:
|
||||
@ -42,6 +43,14 @@ BRIDGE_SERVER_URL='https://xworkmate-bridge.svc.plus' \
|
||||
bash scripts/ci/verify_api_interface_contract.sh
|
||||
```
|
||||
|
||||
双入口验证示例:
|
||||
|
||||
```bash
|
||||
REVIEW_ACCOUNT_LOGIN_PASSWORD='***REMOVED-CREDENTIAL***' \
|
||||
BRIDGE_SERVER_URLS='https://xworkmate-bridge.svc.plus,https://cn-xworkmate-bridge.svc.plus' \
|
||||
bash scripts/ci/verify_api_interface_contract.sh
|
||||
```
|
||||
|
||||
## 3. 默认校验入口
|
||||
|
||||
推荐使用 `Makefile` 目标:
|
||||
@ -64,6 +73,7 @@ make check-api-external
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/session`
|
||||
- `GET /api/auth/xworkmate/profile/sync`
|
||||
- `GET /api/ping`
|
||||
- `POST /acp/rpc` with `acp.capabilities`
|
||||
- `POST /acp/rpc` with `xworkmate.routing.resolve`
|
||||
|
||||
|
||||
@ -1016,6 +1016,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}
|
||||
|
||||
Uri? resolveBridgeAcpEndpointInternal() {
|
||||
final selfHosted = settingsControllerInternal
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted;
|
||||
final selfHostedUrl = selfHosted.serverUrl.trim();
|
||||
if (selfHosted.isConfigured && selfHostedUrl.isNotEmpty) {
|
||||
final uri = Uri.tryParse(selfHostedUrl);
|
||||
if (uri != null && uri.hasScheme && uri.host.trim().isNotEmpty) {
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
}
|
||||
|
||||
final uri = Uri.parse(kManagedBridgeServerUrl);
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
@ -1029,11 +1041,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
if (bridgeEndpoint == null) {
|
||||
return false;
|
||||
}
|
||||
final selfHosted = settingsControllerInternal
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted;
|
||||
if (selfHosted.isConfigured) {
|
||||
return true;
|
||||
}
|
||||
final accountSyncState = settingsControllerInternal.accountSyncState;
|
||||
if (settingsControllerInternal.accountSignedIn &&
|
||||
accountSyncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||
accountSyncState?.tokenConfigured.bridge == true) {
|
||||
return true;
|
||||
}
|
||||
if (settingsControllerInternal.accountSignedIn) {
|
||||
return false;
|
||||
}
|
||||
final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN');
|
||||
return envToken != null && envToken.isNotEmpty;
|
||||
}
|
||||
@ -1072,6 +1095,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
normalizedHost == bridgeHost &&
|
||||
(bridgePort <= 0 || endpoint.port == bridgePort);
|
||||
if (matchesBridgeEndpoint) {
|
||||
final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal();
|
||||
if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) {
|
||||
return manualBridgeToken;
|
||||
}
|
||||
final bridgeToken = await _resolveManagedBridgeAuthTokenInternal();
|
||||
if (bridgeToken != null && bridgeToken.isNotEmpty) {
|
||||
return bridgeToken;
|
||||
@ -1097,15 +1124,37 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> _resolveManualBridgeAuthTokenInternal() async {
|
||||
final selfHosted = settingsControllerInternal
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted;
|
||||
if (!selfHosted.isConfigured) {
|
||||
return null;
|
||||
}
|
||||
final passwordRef = selfHosted.passwordRef.trim();
|
||||
if (passwordRef.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final token = (await storeInternal.loadSecretValueByRef(
|
||||
passwordRef,
|
||||
))?.trim();
|
||||
return token?.isNotEmpty == true ? token : null;
|
||||
}
|
||||
|
||||
Future<String?> _resolveManagedBridgeAuthTokenInternal() async {
|
||||
final accountSyncState = settingsControllerInternal.accountSyncState;
|
||||
if (settingsControllerInternal.accountSignedIn &&
|
||||
accountSyncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||
accountSyncState?.tokenConfigured.bridge == true) {
|
||||
final bridgeToken = (await storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
))?.trim();
|
||||
return bridgeToken?.isNotEmpty == true ? bridgeToken : null;
|
||||
}
|
||||
if (settingsControllerInternal.accountSignedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN');
|
||||
return envToken?.isNotEmpty == true ? envToken : null;
|
||||
|
||||
@ -58,6 +58,17 @@ Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
|
||||
.decodeStream(response)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (response.statusCode == HttpStatus.unauthorized ||
|
||||
response.statusCode == HttpStatus.forbidden) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unauthorized',
|
||||
'message': 'Bridge authorization rejected',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
@ -241,6 +252,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_lastSavedAccountIdentifier = nextSettings.accountUsername;
|
||||
_lastSavedBridgeUrl =
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
if (isManualBridge &&
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
|
||||
unawaited(_refreshBridgeCapabilities());
|
||||
await _refreshAboutSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loginAccount(SettingsSnapshot settings) async {
|
||||
@ -254,6 +270,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
password: _accountPasswordController.text,
|
||||
);
|
||||
await _refreshBridgeCapabilities();
|
||||
await _verifyAccountBridgeRuntimeAccess();
|
||||
} finally {
|
||||
_accountPasswordController.clear();
|
||||
}
|
||||
@ -261,11 +278,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
Future<void> _syncAccount(SettingsSnapshot settings) async {
|
||||
await _persistAccountProfileSettings(settings, isManualBridge: false);
|
||||
await widget.controller.settingsController.syncAccountSettings(
|
||||
baseUrl: _accountBaseUrlController.text.trim(),
|
||||
);
|
||||
final result = await widget.controller.settingsController
|
||||
.syncAccountSettings(baseUrl: _accountBaseUrlController.text.trim());
|
||||
await _refreshBridgeCapabilities();
|
||||
await _refreshAboutSnapshot();
|
||||
if (result.state == 'ready') {
|
||||
await _verifyAccountBridgeRuntimeAccess();
|
||||
} else {
|
||||
await _refreshAboutSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyAccountMfa(SettingsSnapshot settings) async {
|
||||
@ -276,6 +296,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
code: _accountMfaCodeController.text.trim(),
|
||||
);
|
||||
await _refreshBridgeCapabilities();
|
||||
await _verifyAccountBridgeRuntimeAccess();
|
||||
} finally {
|
||||
_accountMfaCodeController.clear();
|
||||
}
|
||||
@ -337,13 +358,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<SettingsAboutSnapshot> _loadAboutSnapshot() async {
|
||||
final bridgeMetadata = await _loadBridgeMetadata();
|
||||
final bridgeEndpoint =
|
||||
widget.controller.resolveGatewayAcpEndpointInternal() ??
|
||||
Uri.parse(kManagedBridgeServerUrl);
|
||||
final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint);
|
||||
return SettingsAboutSnapshot(
|
||||
appVersion: kAppVersion,
|
||||
appBuildNumber: kAppBuildNumber,
|
||||
appBuildDate: kAppBuildDate,
|
||||
appCommit: kAppBuildCommit,
|
||||
bridgeEndpoint: kManagedBridgeServerUrl,
|
||||
bridgeEndpoint: bridgeEndpoint.toString(),
|
||||
bridgeStatus: _stringValue(bridgeMetadata['status']),
|
||||
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
|
||||
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
|
||||
@ -352,14 +376,69 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
|
||||
Future<Map<String, dynamic>> _loadBridgeMetadata(Uri bridgeEndpoint) async {
|
||||
return loadBridgeMetadataForSettingsAbout(
|
||||
bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl),
|
||||
bridgeEndpoint: bridgeEndpoint,
|
||||
authorizationResolver:
|
||||
widget.controller.resolveGatewayAcpAuthorizationHeaderInternal,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _verifyAccountBridgeRuntimeAccess() async {
|
||||
if (!widget.controller.settingsController.accountSignedIn) {
|
||||
await _refreshAboutSnapshot();
|
||||
return;
|
||||
}
|
||||
final bridgeEndpoint =
|
||||
widget.controller.resolveGatewayAcpEndpointInternal() ??
|
||||
Uri.parse(kManagedBridgeServerUrl);
|
||||
final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint);
|
||||
final status = _stringValue(bridgeMetadata['status']).toLowerCase();
|
||||
if (status == 'ok') {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aboutSnapshot = _aboutSnapshotFromMetadata(
|
||||
bridgeEndpoint,
|
||||
bridgeMetadata,
|
||||
);
|
||||
_aboutBusy = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status == 'unauthorized') {
|
||||
await widget.controller.settingsController
|
||||
.markAccountBridgeRuntimeUnavailable('Bridge authorization rejected');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aboutSnapshot = _aboutSnapshotFromMetadata(
|
||||
bridgeEndpoint,
|
||||
bridgeMetadata,
|
||||
);
|
||||
_aboutBusy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SettingsAboutSnapshot _aboutSnapshotFromMetadata(
|
||||
Uri bridgeEndpoint,
|
||||
Map<String, dynamic> bridgeMetadata,
|
||||
) {
|
||||
return SettingsAboutSnapshot(
|
||||
appVersion: kAppVersion,
|
||||
appBuildNumber: kAppBuildNumber,
|
||||
appBuildDate: kAppBuildDate,
|
||||
appCommit: kAppBuildCommit,
|
||||
bridgeEndpoint: bridgeEndpoint.toString(),
|
||||
bridgeStatus: _stringValue(bridgeMetadata['status']),
|
||||
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
|
||||
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
|
||||
bridgeCommit: _stringValue(bridgeMetadata['commit']),
|
||||
bridgeImage: _stringValue(bridgeMetadata['image']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
|
||||
@ -107,6 +107,10 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
Future<AccountSyncResult> syncAccountManagedSecrets({String baseUrl = ''}) =>
|
||||
syncAccountSettings(baseUrl: baseUrl);
|
||||
|
||||
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailable(
|
||||
String message,
|
||||
) => markAccountBridgeRuntimeUnavailableInternal(this, message: message);
|
||||
|
||||
Future<void> logoutAccount() => logoutAccountSettingsInternal(this);
|
||||
|
||||
Future<void> cancelAccountMfaChallenge() =>
|
||||
|
||||
@ -423,6 +423,24 @@ Future<void> logoutAccountSettingsInternal(
|
||||
}
|
||||
}
|
||||
|
||||
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailableInternal(
|
||||
SettingsController controller, {
|
||||
required String message,
|
||||
}) async {
|
||||
final current = controller.accountSyncStateInternal;
|
||||
final nextState = (current ?? AccountSyncState.defaults()).copyWith(
|
||||
syncState: 'blocked',
|
||||
syncMessage: message,
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: message,
|
||||
profileScope: 'bridge',
|
||||
);
|
||||
await _persistAccountSyncStateInternal(controller, nextState);
|
||||
controller.accountStatusInternal = message;
|
||||
controller.notifyListeners();
|
||||
return AccountSyncResult(state: 'blocked', message: message);
|
||||
}
|
||||
|
||||
Future<void> cancelAccountMfaChallengeSettingsInternal(
|
||||
SettingsController controller,
|
||||
) async {
|
||||
|
||||
@ -5,6 +5,7 @@ ACCOUNTS_BASE_URL="${REVIEW_ACCOUNT_BASE_URL:-https://accounts.svc.plus}"
|
||||
REVIEW_ACCOUNT_LOGIN_NAME="${REVIEW_ACCOUNT_LOGIN_NAME:-review@svc.plus}"
|
||||
REVIEW_ACCOUNT_LOGIN_PASSWORD="${REVIEW_ACCOUNT_LOGIN_PASSWORD:-}"
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-}"
|
||||
BRIDGE_SERVER_URLS="${BRIDGE_SERVER_URLS:-}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}"
|
||||
|
||||
@ -181,10 +182,8 @@ if not bridge_token:
|
||||
raise SystemExit("sync response did not include BRIDGE_AUTH_TOKEN")
|
||||
PY
|
||||
|
||||
bridge_server_url="${BRIDGE_SERVER_URL}"
|
||||
if [[ -z "${bridge_server_url}" ]]; then
|
||||
bridge_server_url="$(
|
||||
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
|
||||
synced_bridge_server_url="$(
|
||||
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@ -194,9 +193,18 @@ if not bridge_url:
|
||||
raise SystemExit("sync response did not include BRIDGE_SERVER_URL")
|
||||
print(bridge_url.rstrip("/"))
|
||||
PY
|
||||
)"
|
||||
)"
|
||||
|
||||
bridge_server_urls=()
|
||||
if [[ -n "${BRIDGE_SERVER_URLS}" ]]; then
|
||||
while IFS= read -r candidate; do
|
||||
[[ -n "${candidate}" ]] && bridge_server_urls+=("$(normalize_url "${candidate}")")
|
||||
done < <(printf '%s\n' "${BRIDGE_SERVER_URLS}" | tr ',' '\n' | tr '[:space:]' '\n' | sed '/^$/d')
|
||||
elif [[ -n "${BRIDGE_SERVER_URL}" ]]; then
|
||||
bridge_server_urls+=("$(normalize_url "${BRIDGE_SERVER_URL}")")
|
||||
else
|
||||
bridge_server_urls+=("$(normalize_url "${synced_bridge_server_url}")")
|
||||
fi
|
||||
bridge_server_url="$(normalize_url "${bridge_server_url}")"
|
||||
|
||||
bridge_auth_token="${BRIDGE_AUTH_TOKEN}"
|
||||
if [[ -z "${bridge_auth_token}" ]]; then
|
||||
@ -214,13 +222,29 @@ PY
|
||||
)"
|
||||
fi
|
||||
|
||||
capabilities_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
|
||||
verified_urls=()
|
||||
for bridge_server_url in "${bridge_server_urls[@]}"; do
|
||||
ping_json="$(
|
||||
json_get \
|
||||
"${bridge_server_url}/api/ping" \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${ping_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = json.loads(os.environ["RESPONSE_JSON"])
|
||||
if payload.get("status") != "ok":
|
||||
raise SystemExit("bridge ping status is not ok")
|
||||
PY
|
||||
|
||||
capabilities_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@ -232,10 +256,10 @@ if result.get("availableExecutionTargets") != ["agent", "gateway"]:
|
||||
raise SystemExit("unexpected availableExecutionTargets")
|
||||
PY
|
||||
|
||||
routing_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{
|
||||
routing_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{
|
||||
"jsonrpc":"2.0",
|
||||
"id":"routing",
|
||||
"method":"xworkmate.routing.resolve",
|
||||
@ -254,9 +278,9 @@ routing_json="$(
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${routing_json}" python3 - <<'PY'
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${routing_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@ -267,5 +291,7 @@ if not isinstance(result, dict):
|
||||
if result.get("resolvedProviderId") != "codex":
|
||||
raise SystemExit("unexpected resolvedProviderId")
|
||||
PY
|
||||
verified_urls+=("${bridge_server_url}")
|
||||
done
|
||||
|
||||
printf 'API interface contract verified via %s\n' "${bridge_server_url}"
|
||||
printf 'API interface contract verified via %s\n' "${verified_urls[*]}"
|
||||
|
||||
@ -208,8 +208,6 @@ payload = json.loads(os.environ["RESPONSE_JSON"])
|
||||
result = payload.get("result") or payload.get("payload") or {}
|
||||
if result.get("resolvedProviderId") != "codex":
|
||||
raise SystemExit("session.start did not resolve codex")
|
||||
if not str(result.get("error") or "").strip():
|
||||
raise SystemExit("session.start in this environment should expose downstream error details")
|
||||
PY
|
||||
|
||||
RESPONSE_JSON="${message_json}" python3 - <<'PY'
|
||||
|
||||
@ -108,7 +108,7 @@ void main() {
|
||||
expect(metadata['buildDate'], '');
|
||||
});
|
||||
|
||||
test('returns unavailable when authorized bridge ping fails', () async {
|
||||
test('returns unauthorized when bridge rejects authorization', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async {
|
||||
await server.close(force: true);
|
||||
@ -126,7 +126,8 @@ void main() {
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
|
||||
expect(metadata['status'], 'unavailable');
|
||||
expect(metadata['status'], 'unauthorized');
|
||||
expect(metadata['message'], 'Bridge authorization rejected');
|
||||
expect(metadata['version'], '');
|
||||
expect(metadata['commit'], '');
|
||||
expect(metadata['image'], '');
|
||||
|
||||
@ -114,6 +114,65 @@ void main() {
|
||||
expect(submittedPassword, 'typed-password');
|
||||
});
|
||||
|
||||
testWidgets('manual bridge save submits current field values', (
|
||||
tester,
|
||||
) async {
|
||||
final controllers = _TestControllers();
|
||||
addTearDown(controllers.dispose);
|
||||
|
||||
var savedAsManualBridge = false;
|
||||
var savedBridgeUrl = '';
|
||||
var savedBridgeToken = '';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SettingsAccountPanel(
|
||||
settings: SettingsSnapshot.defaults(),
|
||||
accountSession: null,
|
||||
accountState: null,
|
||||
accountBusy: false,
|
||||
accountSignedIn: false,
|
||||
accountMfaRequired: false,
|
||||
accountBaseUrlController: controllers.baseUrl,
|
||||
accountIdentifierController: controllers.identifier,
|
||||
accountPasswordController: controllers.password,
|
||||
accountMfaCodeController: controllers.mfaCode,
|
||||
bridgeUrlController: controllers.bridgeUrl,
|
||||
bridgeTokenController: controllers.bridgeToken,
|
||||
onSaveAccountProfile: ({required bool isManualBridge}) async {
|
||||
savedAsManualBridge = isManualBridge;
|
||||
savedBridgeUrl = controllers.bridgeUrl.text;
|
||||
savedBridgeToken = controllers.bridgeToken.text;
|
||||
},
|
||||
onLogin: () async {},
|
||||
onVerifyMfa: () async {},
|
||||
onCancelMfa: () async {},
|
||||
onSync: () async {},
|
||||
onLogout: () async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('手动 Bridge 配置'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-url-field')),
|
||||
'https://cn-xworkmate-bridge.svc.plus',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-token-field')),
|
||||
'typed-manual-token',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-save-button')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(savedAsManualBridge, isTrue);
|
||||
expect(savedBridgeUrl, 'https://cn-xworkmate-bridge.svc.plus');
|
||||
expect(savedBridgeToken, 'typed-manual-token');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows account sync status, resync, and exit in signed-in mode',
|
||||
(tester) async {
|
||||
|
||||
@ -441,6 +441,144 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'blocked managed bridge sync does not configure runtime authorization',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-blocked-runtime-',
|
||||
);
|
||||
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.saveAccountSessionToken('session-token');
|
||||
await store.saveAccountSessionSummary(
|
||||
const AccountSessionSummary(
|
||||
userId: 'user-1',
|
||||
email: 'review@svc.plus',
|
||||
name: 'Review User',
|
||||
role: 'reviewer',
|
||||
mfaEnabled: true,
|
||||
),
|
||||
);
|
||||
await store.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncState: 'ready',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{
|
||||
'BRIDGE_AUTH_TOKEN': 'env-token-must-not-recover-blocked-sync',
|
||||
},
|
||||
store: store,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
await controller.settingsControllerInternal
|
||||
.markAccountBridgeRuntimeUnavailable(
|
||||
'Bridge authorization rejected',
|
||||
);
|
||||
|
||||
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await store.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
),
|
||||
'bridge-token',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('manual bridge config becomes the runtime ACP source', () async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-manual-bridge-runtime-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here. The controller may still be
|
||||
// releasing files when teardown starts.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
|
||||
.copyWith(
|
||||
selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted
|
||||
.copyWith(
|
||||
serverUrl: 'https://private-bridge.svc.plus',
|
||||
username: 'admin',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveSecretValueByRef(
|
||||
AcpBridgeServerSelfHostedConfig.defaults().passwordRef,
|
||||
'manual-bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
store: store,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
expect(
|
||||
controller.resolveGatewayAcpEndpointInternal()?.toString(),
|
||||
'https://private-bridge.svc.plus',
|
||||
);
|
||||
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isTrue);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://private-bridge.svc.plus/acp/rpc'),
|
||||
),
|
||||
'manual-bridge-token',
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'syncAccountSettings succeeds when bridge url metadata is missing',
|
||||
() async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user