import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/gateway_runtime_session_client.dart'; import 'package:xworkmate/runtime/mode_switcher.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('Bridge runtime cleanup', () { test( 'keeps the managed bridge endpoint fixed even when account sync carries a bridge URL and stale manual bridge config exists', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-bridge-runtime-cleanup-', ); addTearDown(() async { if (await storeRoot.exists()) { try { await storeRoot.delete(recursive: true); } on FileSystemException { // Best-effort cleanup. Flutter tests can still hold temporary files // briefly 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.saveAccountSyncState( AccountSyncState.defaults().copyWith( syncedDefaults: AccountRemoteProfile.defaults().copyWith( bridgeServerUrl: 'https://xworkmate-bridge-alt.svc.plus', ), syncState: 'ready', tokenConfigured: const AccountTokenConfigured( bridge: true, vault: false, ), ), ); 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.saveSettingsSnapshot( SettingsSnapshot.defaults().copyWith( acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() .copyWith( selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted .copyWith( serverUrl: 'https://acp-bridge.onwalk.net', username: 'admin', ), ), ), ); await store.saveAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, value: 'bridge-token', ); final controller = AppController( store: store, environmentOverride: const { 'BRIDGE_SERVER_URL': 'https://stale.example.invalid', }, ); addTearDown(controller.dispose); await controller.settingsControllerInternal.initialize(); expect( controller.resolveBridgeAcpEndpointInternal()?.toString(), kManagedBridgeServerUrl, ); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), ), 'bridge-token', ); expect( controller .resolveExternalAcpEndpointForTargetInternal( AssistantExecutionTarget.gateway, ) ?.toString(), kManagedBridgeServerUrl, ); expect(await store.loadAccountSyncState(), isNotNull); expect( (await store.loadAccountSyncState())!.syncedDefaults.bridgeServerUrl, 'https://xworkmate-bridge-alt.svc.plus', ); }, ); test('keeps the managed bridge endpoint fixed when signed out', () { final controller = AppController( environmentOverride: const { 'BRIDGE_SERVER_URL': 'https://stale.example.invalid', }, ); addTearDown(controller.dispose); expect( controller.resolveBridgeAcpEndpointInternal()?.toString(), kManagedBridgeServerUrl, ); }); test( 'resolves raw bridge token only for the current managed bridge endpoint', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-bridge-auth-resolver-', ); 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.saveAccountSessionToken('session-token'); await store.saveAccountSessionSummary( const AccountSessionSummary( userId: 'user-1', email: 'review@svc.plus', name: 'Review User', role: 'reviewer', mfaEnabled: true, ), ); await store.saveAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, value: 'bridge-token', ); await store.saveAccountSyncState( AccountSyncState.defaults().copyWith( syncedDefaults: AccountRemoteProfile.defaults().copyWith( bridgeServerUrl: kManagedBridgeServerUrl, ), syncState: 'ready', tokenConfigured: const AccountTokenConfigured( bridge: true, vault: false, ), ), ); final controller = AppController( environmentOverride: const {}, store: store, ); addTearDown(controller.dispose); await controller.settingsControllerInternal.initialize(); final bridgeHeader = await controller .resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), ); final unrelatedHeader = await controller .resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('https://unrelated.example.com/acp/rpc'), ); expect(bridgeHeader, 'bridge-token'); expect(unrelatedHeader, isNull); }, ); test( 'manual bridge token authorizes runtime and artifact requests only for manual endpoint', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-manual-bridge-artifact-auth-', ); 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 {}, store: store, ); addTearDown(controller.dispose); await controller.settingsControllerInternal.initialize(); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('https://private-bridge.svc.plus/acp/rpc'), ), 'manual-bridge-token', ); expect( await controller.resolveBridgeArtifactAuthorizationHeaderInternal( Uri.parse('https://private-bridge.svc.plus/artifacts/file.pdf'), ), 'Bearer manual-bridge-token', ); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), ), isNull, ); expect( await controller.resolveBridgeArtifactAuthorizationHeaderInternal( Uri.parse('$kManagedBridgeServerUrl/artifacts/file.pdf'), ), isNull, ); }, ); test( 'runtime coordinator only exposes remote and offline gateway modes', () { final controller = AppController( environmentOverride: const {}, ); addTearDown(controller.dispose); expect( controller.runtimeCoordinatorInternal.getAvailableModes(), const [GatewayMode.remote, GatewayMode.offline], ); }, ); test( 'session client bootstrap uses the managed bridge instead of local ACP', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-bridge-session-bootstrap-', ); addTearDown(() async { if (await storeRoot.exists()) { try { await storeRoot.delete(recursive: true); } on FileSystemException { // Temp cleanup is best effort while Flutter test teardown releases IO. } } }); final store = SecureConfigStore( secretRootPathResolver: () async => '${storeRoot.path}/secrets', appDataRootPathResolver: () async => '${storeRoot.path}/app-data', supportRootPathResolver: () async => '${storeRoot.path}/support', enableSecureStorage: false, ); await store.initialize(); final sessionClient = _CapturingGatewayRuntimeSessionClient(); final runtime = GatewayRuntime( store: store, identityStore: DeviceIdentityStore(store), sessionClient: sessionClient, runtimeId: 'runtime-session-bootstrap-test', ); addTearDown(runtime.dispose); await runtime.initialize(); await runtime.ensureBridgeSessionConnected(selectedAgentId: 'codex'); final request = sessionClient.lastConnectRequest; expect(request, isNotNull); expect(request!.mode, RuntimeConnectionMode.remote); expect(request.host, Uri.parse(kManagedBridgeServerUrl).host); expect(request.port, 443); expect(request.tls, isTrue); expect(request.host, isNot(anyOf('127.0.0.1', 'localhost'))); }, ); }); } class _CapturingGatewayRuntimeSessionClient implements GatewayRuntimeSessionClient { final StreamController _updates = StreamController.broadcast(); GatewayRuntimeSessionConnectRequest? lastConnectRequest; @override Stream get updates => _updates.stream; @override Future connect( GatewayRuntimeSessionConnectRequest request, ) async { lastConnectRequest = request; return GatewayRuntimeSessionConnectResult( snapshot: GatewayConnectionSnapshot.initial(mode: request.mode).copyWith( status: RuntimeConnectionStatus.connected, statusText: 'Connected', remoteAddress: '${request.host}:${request.port}', deviceId: request.identity.deviceId, ), auth: const {'role': 'operator'}, returnedDeviceToken: '', raw: const {}, ); } @override Future disconnect({required String runtimeId}) async {} @override Future request({ required String runtimeId, required String method, Map? params, Duration timeout = const Duration(seconds: 15), bool allowErrorPayload = false, }) { throw UnimplementedError('request is not used by this cleanup test'); } @override Future dispose() => _updates.close(); }