diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart index b872ddea..3a785965 100644 --- a/lib/runtime/secret_store.dart +++ b/lib/runtime/secret_store.dart @@ -83,45 +83,24 @@ class FileSecureStorageClient implements SecureStorageClient { } } -class MemorySecureStorageClient implements SecureStorageClient { - final Map _values = {}; - - @override - Future delete({required String key}) async { - _values.remove(key); - } - - @override - Future read({required String key}) async { - return _values[key]; - } - - @override - Future write({required String key, required String value}) async { - _values[key] = value; - } -} - class SecretStore { SecretStore({ Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, Future Function()? defaultSupportDirectoryPathResolver, - bool allowInMemoryFallback = false, SecureStorageClient? secureStorage, bool enableSecureStorage = true, }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, _databasePathResolver = databasePathResolver, _defaultSupportDirectoryPathResolver = defaultSupportDirectoryPathResolver, - _allowInMemoryFallback = allowInMemoryFallback, _secureStorageOverride = secureStorage, _enableSecureStorage = enableSecureStorage; static const Duration _secureStorageTimeout = Duration(seconds: 5); static const String legacyLocalStateKey = 'xworkmate.local_state.key'; - static const String _gatewayTokenKey = 'xworkmate.gateway.token'; - static const String _gatewayPasswordKey = 'xworkmate.gateway.password'; + static const String _legacyGatewayTokenKey = 'xworkmate.gateway.token'; + static const String _legacyGatewayPasswordKey = 'xworkmate.gateway.password'; static const String _gatewayDeviceIdKey = 'xworkmate.gateway.device.id'; static const String _gatewayDevicePublicKeyKey = 'xworkmate.gateway.device.public_key'; @@ -132,8 +111,8 @@ class SecretStore { static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key'; static const Map _legacyFallbackFileNames = { - _gatewayTokenKey: 'gateway-token.txt', - _gatewayPasswordKey: 'gateway-password.txt', + _legacyGatewayTokenKey: 'gateway-token.txt', + _legacyGatewayPasswordKey: 'gateway-password.txt', _ollamaCloudApiKeyKey: 'ollama-cloud-api-key.txt', _vaultTokenKey: 'vault-token.txt', _aiGatewayApiKeyKey: 'ai-gateway-api-key.txt', @@ -143,7 +122,6 @@ class SecretStore { final Future Function()? _fallbackDirectoryPathResolver; final Future Function()? _databasePathResolver; final Future Function()? _defaultSupportDirectoryPathResolver; - final bool _allowInMemoryFallback; final SecureStorageClient? _secureStorageOverride; final bool _enableSecureStorage; SecureStorageClient? _secureStorage; @@ -154,20 +132,18 @@ class SecretStore { return; } await _ensureDurableStorageLayout(); - if (_enableSecureStorage) { - if (_secureStorageOverride != null) { - _secureStorage = _secureStorageOverride; - } else if (_useDebugSecureStorageFallback()) { - _secureStorage = _buildDebugSecureStorageClient(); - } else { - try { - _secureStorage = FlutterSecureStorageClient( - const FlutterSecureStorage(), - ); - } catch (_) { - _secureStorage = null; - } + if (_secureStorageOverride != null) { + _secureStorage = _secureStorageOverride; + } else if (_enableSecureStorage) { + try { + _secureStorage = FlutterSecureStorageClient( + const FlutterSecureStorage(), + ); + } catch (_) { + _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); } + } else { + _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); } _initialized = true; } @@ -175,9 +151,6 @@ class SecretStore { Future _ensureDurableStorageLayout() async { final fallbackDirectory = await _resolveFallbackDirectory(); if (fallbackDirectory == null) { - if (_allowInMemoryFallback) { - return; - } throw StateError( 'Durable secret storage layout unavailable: cannot resolve fallback directory.', ); @@ -190,19 +163,59 @@ class SecretStore { } } - Future loadGatewayToken() => _readSecure(_gatewayTokenKey); + Future loadGatewayToken({int? profileIndex}) async { + if (profileIndex != null) { + final scopedValue = await _readSecure( + _gatewayTokenKeyForProfile(profileIndex), + ); + if ((scopedValue ?? '').trim().isNotEmpty) { + return scopedValue; + } + } + return _readSecure(_legacyGatewayTokenKey); + } - Future saveGatewayToken(String value) => - _writeSecure(_gatewayTokenKey, value); + Future saveGatewayToken(String value, {int? profileIndex}) => + _writeSecure( + profileIndex == null + ? _legacyGatewayTokenKey + : _gatewayTokenKeyForProfile(profileIndex), + value, + ); - Future clearGatewayToken() => _deleteSecure(_gatewayTokenKey); + Future clearGatewayToken({int? profileIndex}) => + _deleteSecure( + profileIndex == null + ? _legacyGatewayTokenKey + : _gatewayTokenKeyForProfile(profileIndex), + ); - Future loadGatewayPassword() => _readSecure(_gatewayPasswordKey); + Future loadGatewayPassword({int? profileIndex}) async { + if (profileIndex != null) { + final scopedValue = await _readSecure( + _gatewayPasswordKeyForProfile(profileIndex), + ); + if ((scopedValue ?? '').trim().isNotEmpty) { + return scopedValue; + } + } + return _readSecure(_legacyGatewayPasswordKey); + } - Future saveGatewayPassword(String value) => - _writeSecure(_gatewayPasswordKey, value); + Future saveGatewayPassword(String value, {int? profileIndex}) => + _writeSecure( + profileIndex == null + ? _legacyGatewayPasswordKey + : _gatewayPasswordKeyForProfile(profileIndex), + value, + ); - Future clearGatewayPassword() => _deleteSecure(_gatewayPasswordKey); + Future clearGatewayPassword({int? profileIndex}) => + _deleteSecure( + profileIndex == null + ? _legacyGatewayPasswordKey + : _gatewayPasswordKeyForProfile(profileIndex), + ); Future loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey); @@ -223,8 +236,8 @@ class SecretStore { Future> loadSecureRefs() async { await initialize(); - final gatewayToken = await loadGatewayToken(); - final gatewayPassword = await loadGatewayPassword(); + final legacyGatewayToken = await _readSecure(_legacyGatewayTokenKey); + final legacyGatewayPassword = await _readSecure(_legacyGatewayPasswordKey); final deviceIdentity = await loadDeviceIdentity(); final deviceToken = deviceIdentity == null ? null @@ -236,12 +249,24 @@ class SecretStore { final vaultToken = await loadVaultToken(); final aiGatewayApiKey = await loadAiGatewayApiKey(); final secureRefs = {}; - if (gatewayToken case final value?) { + if (legacyGatewayToken case final value?) { secureRefs['gateway_token'] = value; } - if (gatewayPassword case final value?) { + if (legacyGatewayPassword case final value?) { secureRefs['gateway_password'] = value; } + for (var index = 0; index < kGatewayProfileListLength; index += 1) { + final scopedToken = await _readSecure(_gatewayTokenKeyForProfile(index)); + final scopedPassword = await _readSecure( + _gatewayPasswordKeyForProfile(index), + ); + if (scopedToken case final value?) { + secureRefs[_gatewayTokenRefKey(index)] = value; + } + if (scopedPassword case final value?) { + secureRefs[_gatewayPasswordRefKey(index)] = value; + } + } if (deviceToken case final value?) { secureRefs['gateway_device_token_operator'] = value; } @@ -257,6 +282,12 @@ class SecretStore { return secureRefs; } + static String gatewayTokenRefKey(int profileIndex) => + _gatewayTokenRefKey(profileIndex); + + static String gatewayPasswordRefKey(int profileIndex) => + _gatewayPasswordRefKey(profileIndex); + Future loadDeviceIdentity() async { await initialize(); final deviceId = await _readSecure(_gatewayDeviceIdKey); @@ -334,9 +365,6 @@ class SecretStore { } Future dispose() async { - if (_allowInMemoryFallback && _memorySecure.isNotEmpty) { - await _syncMemorySecretsToDurableStore(); - } _secureStorage = null; _initialized = false; _memorySecure.clear(); @@ -363,32 +391,34 @@ class SecretStore { if (migrated != null && migrated.trim().isNotEmpty) { return migrated.trim(); } - return _memorySecure[key]; + return null; } Future _readSecureRaw(String key) async { - if (_secureStorage != null) { - try { - final value = await _readSecureValue(_secureStorage!, key); - if (value != null && value.trim().isNotEmpty) { - _memorySecure[key] = value.trim(); - return value.trim(); - } - } catch (_) { - if (await _promoteToFileSecureStorageFallback()) { - try { - final value = await _readSecureValue(_secureStorage!, key); - if (value != null && value.trim().isNotEmpty) { - _memorySecure[key] = value.trim(); - return value.trim(); - } - } catch (_) { - // Fall through to in-memory cache. - } - } + final client = await _ensureSecureStorageClient(); + try { + final value = await _readSecureValue(client, key); + if (value == null || value.trim().isEmpty) { + return null; } + final trimmed = value.trim(); + _memorySecure[key] = trimmed; + return trimmed; + } catch (_) { + final promoted = await _promoteToFileSecureStorageFallback(); + if (!promoted || _secureStorage == null) { + throw StateError( + 'Durable secret storage unavailable for $key: failed to read secure value.', + ); + } + final value = await _readSecureValue(_secureStorage!, key); + if (value == null || value.trim().isEmpty) { + return null; + } + final trimmed = value.trim(); + _memorySecure[key] = trimmed; + return trimmed; } - return _memorySecure[key]; } Future _writeSecure(String key, String value) async { @@ -397,22 +427,9 @@ class SecretStore { if (trimmed.isEmpty) { return; } - if (_secureStorage == null && - !await _promoteToFileSecureStorageFallback()) { - if (_allowInMemoryFallback) { - _memorySecure[key] = trimmed; - unawaited(_syncMemorySecretsToDurableStore()); - return; - } - throw StateError( - 'Durable secret storage unavailable for $key: secure storage and file fallback both failed.', - ); - } - if (_secureStorage == null) { - return; - } + final client = await _ensureSecureStorageClient(); try { - await _writeSecureValue(_secureStorage!, key, trimmed); + await _writeSecureValue(client, key, trimmed); _memorySecure[key] = trimmed; final file = await _legacyFallbackFile(key); if (file != null && await file.exists()) { @@ -421,21 +438,12 @@ class SecretStore { } catch (_) { final promoted = await _promoteToFileSecureStorageFallback(); if (promoted && _secureStorage != null) { - try { - await _writeSecureValue(_secureStorage!, key, trimmed); - _memorySecure[key] = trimmed; - final file = await _legacyFallbackFile(key); - if (file != null && await file.exists()) { - await file.delete(); - } - return; - } catch (_) { - // Fall through to strict fallback handling below. - } - } - if (_allowInMemoryFallback) { + await _writeSecureValue(_secureStorage!, key, trimmed); _memorySecure[key] = trimmed; - unawaited(_syncMemorySecretsToDurableStore()); + final file = await _legacyFallbackFile(key); + if (file != null && await file.exists()) { + await file.delete(); + } return; } throw StateError( @@ -446,12 +454,17 @@ class SecretStore { Future _deleteSecure(String key) async { await initialize(); - if (_secureStorage != null) { - try { - await _deleteSecureValue(_secureStorage!, key); - } catch (_) { - // Best effort. + final client = await _ensureSecureStorageClient(); + try { + await _deleteSecureValue(client, key); + } catch (_) { + final promoted = await _promoteToFileSecureStorageFallback(); + if (!promoted || _secureStorage == null) { + throw StateError( + 'Durable secret storage unavailable for $key: failed to delete secure value.', + ); } + await _deleteSecureValue(_secureStorage!, key); } _memorySecure.remove(key); final file = await _legacyFallbackFile(key); @@ -502,27 +515,19 @@ class SecretStore { } Future _resolveFallbackDirectory() async { - if (_fallbackDirectoryPathResolver != null) { - String? explicit; - try { - explicit = await _fallbackDirectoryPathResolver(); - } catch (_) { - // Continue to next fallback candidate. - } - final explicitTrimmed = explicit?.trim() ?? ''; - if (explicitTrimmed.isNotEmpty) { - return _requireExistingDirectory(explicitTrimmed); - } + final fallbackRoot = await _resolvePath(_fallbackDirectoryPathResolver); + if (fallbackRoot != null) { + return _ensureDirectory(fallbackRoot); } - - try { - final databasePath = await _databasePathResolver?.call(); - final databaseTrimmed = databasePath?.trim() ?? ''; - if (databaseTrimmed.isNotEmpty) { - return _requireExistingDirectory(File(databaseTrimmed).parent.path); - } - } catch (_) { - // Continue to next fallback candidate. + final databasePath = await _resolvePath(_databasePathResolver); + if (databasePath != null) { + return _ensureDirectory(File(databasePath).parent.path); + } + final defaultSupportRoot = await _resolvePath( + _defaultSupportDirectoryPathResolver, + ); + if (defaultSupportRoot != null) { + return _ensureDirectory('$defaultSupportRoot/gateway-auth'); } try { final supportDirectory = await getApplicationSupportDirectory(); @@ -530,38 +535,7 @@ class SecretStore { '${supportDirectory.path}/xworkmate/gateway-auth', ); } catch (_) { - // Continue below to deterministic fallback. - } - try { - final defaultSupportRoot = await _defaultSupportDirectoryPathResolver - ?.call(); - final trimmed = defaultSupportRoot?.trim() ?? ''; - if (trimmed.isNotEmpty) { - return _ensureDirectory('$trimmed/gateway-auth'); - } - } catch (_) { - // Ignore and fall through. - } - return null; - } - - Future _syncMemorySecretsToDurableStore() async { - if (_memorySecure.isEmpty) { - return; - } - if (_secureStorage == null || _secureStorage is MemorySecureStorageClient) { - final promoted = await _promoteToFileSecureStorageFallback(); - if (!promoted || _secureStorage == null) { - return; - } - } - final snapshot = Map.from(_memorySecure); - for (final entry in snapshot.entries) { - try { - await _writeSecureValue(_secureStorage!, entry.key, entry.value); - } catch (_) { - // Best-effort sync for fallback memory mode. - } + return null; } } @@ -573,19 +547,8 @@ class SecretStore { return directory; } - Future _requireExistingDirectory(String path) async { - final directory = Directory(path); - if (!await directory.exists()) { - throw StateError('Durable secret storage path does not exist: $path'); - } - return directory; - } - Future _promoteToFileSecureStorageFallback() async { - if (_secureStorageOverride != null || - (_databasePathResolver == null && - _fallbackDirectoryPathResolver == null && - _defaultSupportDirectoryPathResolver == null)) { + if (_secureStorageOverride != null) { return false; } final directory = await _resolveFallbackDirectory(); @@ -624,32 +587,51 @@ class SecretStore { return future; } - bool _useDebugSecureStorageFallback() { - var enabled = false; - assert(() { - enabled = true; - return true; - }()); - return enabled; - } - - SecureStorageClient _buildDebugSecureStorageClient() { - if (_databasePathResolver != null || - _fallbackDirectoryPathResolver != null || - _defaultSupportDirectoryPathResolver != null) { - return FileSecureStorageClient(() => _resolveFallbackDirectory()); - } - return MemorySecureStorageClient(); - } - static String _deviceTokenKey(String deviceId, String role) { final safeRole = role.trim().isEmpty ? 'operator' : role.trim(); return 'xworkmate.gateway.device_token.$deviceId.$safeRole'; } + static String _gatewayTokenKeyForProfile(int profileIndex) => + 'xworkmate.gateway.profile.$profileIndex.token'; + + static String _gatewayPasswordKeyForProfile(int profileIndex) => + 'xworkmate.gateway.profile.$profileIndex.password'; + + static String _gatewayTokenRefKey(int profileIndex) => + 'gateway_token_$profileIndex'; + + static String _gatewayPasswordRefKey(int profileIndex) => + 'gateway_password_$profileIndex'; + static List _base64UrlDecode(String value) { final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); return base64.decode(padded); } + + Future _ensureSecureStorageClient() async { + final client = _secureStorage; + if (client != null) { + return client; + } + final promoted = await _promoteToFileSecureStorageFallback(); + if (promoted && _secureStorage != null) { + return _secureStorage!; + } + throw StateError('Durable secret storage unavailable: no persistent secure storage client.'); + } + + Future _resolvePath(Future Function()? resolver) async { + if (resolver == null) { + return null; + } + try { + final resolved = await resolver(); + final trimmed = resolved?.trim() ?? ''; + return trimmed.isEmpty ? null : trimmed; + } catch (_) { + return null; + } + } } diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 2be523b6..c1275bac 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -14,7 +14,6 @@ class SecureConfigStore { Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, Future Function()? defaultSupportDirectoryPathResolver, - bool? allowInMemoryFallback, SecureConfigDatabaseOpener? databaseOpener, SecureStorageClient? secureStorage, bool enableSecureStorage = true, @@ -22,13 +21,11 @@ class SecureConfigStore { final resolvedDefaultSupportDirectoryPathResolver = defaultSupportDirectoryPathResolver ?? _resolveDefaultSupportDirectoryPath; - final resolvedAllowInMemoryFallback = allowInMemoryFallback ?? false; _secretStore = SecretStore( fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, databasePathResolver: databasePathResolver, defaultSupportDirectoryPathResolver: resolvedDefaultSupportDirectoryPathResolver, - allowInMemoryFallback: resolvedAllowInMemoryFallback, secureStorage: secureStorage, enableSecureStorage: enableSecureStorage, ); @@ -37,7 +34,6 @@ class SecureConfigStore { databasePathResolver: databasePathResolver, defaultSupportDirectoryPathResolver: resolvedDefaultSupportDirectoryPathResolver, - allowInMemoryFallback: resolvedAllowInMemoryFallback, databaseOpener: databaseOpener, legacyLocalStateKeyLoader: _secretStore.loadLegacyLocalStateKeyBytes, ); @@ -86,19 +82,23 @@ class SecureConfigStore { return _secretStore.loadSecureRefs(); } - Future loadGatewayToken() => _secretStore.loadGatewayToken(); + Future loadGatewayToken({int? profileIndex}) => + _secretStore.loadGatewayToken(profileIndex: profileIndex); - Future saveGatewayToken(String value) => - _secretStore.saveGatewayToken(value); + Future saveGatewayToken(String value, {int? profileIndex}) => + _secretStore.saveGatewayToken(value, profileIndex: profileIndex); - Future clearGatewayToken() => _secretStore.clearGatewayToken(); + Future clearGatewayToken({int? profileIndex}) => + _secretStore.clearGatewayToken(profileIndex: profileIndex); - Future loadGatewayPassword() => _secretStore.loadGatewayPassword(); + Future loadGatewayPassword({int? profileIndex}) => + _secretStore.loadGatewayPassword(profileIndex: profileIndex); - Future saveGatewayPassword(String value) => - _secretStore.saveGatewayPassword(value); + Future saveGatewayPassword(String value, {int? profileIndex}) => + _secretStore.saveGatewayPassword(value, profileIndex: profileIndex); - Future clearGatewayPassword() => _secretStore.clearGatewayPassword(); + Future clearGatewayPassword({int? profileIndex}) => + _secretStore.clearGatewayPassword(profileIndex: profileIndex); Future loadOllamaCloudApiKey() => _secretStore.loadOllamaCloudApiKey(); diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index d06035b8..63bff0bd 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -13,29 +13,17 @@ import 'runtime_models.dart'; typedef SecureConfigDatabaseOpener = FutureOr Function(String resolvedPath); -class _DatabasePathCandidate { - const _DatabasePathCandidate({ - required this.path, - required this.createParentDirectory, - }); - - final String path; - final bool createParentDirectory; -} - class SettingsStore { SettingsStore({ Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, Future Function()? defaultSupportDirectoryPathResolver, - bool allowInMemoryFallback = false, SecureConfigDatabaseOpener? databaseOpener, Future?> Function()? legacyLocalStateKeyLoader, }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, _databasePathResolver = databasePathResolver, _defaultSupportDirectoryPathResolver = defaultSupportDirectoryPathResolver, - _allowInMemoryFallback = allowInMemoryFallback, _databaseOpener = databaseOpener, _legacyLocalStateKeyLoader = legacyLocalStateKeyLoader; @@ -55,15 +43,12 @@ class SettingsStore { final Future Function()? _fallbackDirectoryPathResolver; final Future Function()? _databasePathResolver; final Future Function()? _defaultSupportDirectoryPathResolver; - final bool _allowInMemoryFallback; final SecureConfigDatabaseOpener? _databaseOpener; final Future?> Function()? _legacyLocalStateKeyLoader; final Cipher _legacyCipher = AesGcm.with256bits(); - final Map _memoryStore = {}; SharedPreferences? _prefs; sqlite.Database? _database; String? _resolvedDatabasePath; - bool _usingInMemoryDatabase = false; bool _initialized = false; bool _recoveryAttempted = false; LegacyRecoveryReport _lastRecoveryReport = const LegacyRecoveryReport(); @@ -94,7 +79,6 @@ class SettingsStore { await initialize(); final encoded = snapshot.toJsonString(); await _writeStoredString(settingsKey, encoded); - await _writeDurableStateFile(settingsKey, encoded); _lastRecoveryReport = const LegacyRecoveryReport(); } @@ -114,7 +98,6 @@ class SettingsStore { records.map((item) => item.toJson()).toList(growable: false), ); await _writeStoredString(assistantThreadsKey, encoded); - await _writeDurableStateFile(assistantThreadsKey, encoded); } Future clearAssistantLocalState() async { @@ -161,9 +144,6 @@ class SettingsStore { } void dispose() { - if (_usingInMemoryDatabase) { - unawaited(_syncInMemoryStoreToDurableStore()); - } final database = _database; _database = null; if (database != null) { @@ -176,64 +156,35 @@ class SettingsStore { _prefs = null; _initialized = false; _resolvedDatabasePath = null; - _usingInMemoryDatabase = false; - _memoryStore.clear(); } Future _initializeDatabase() async { - final candidates = await _resolveDatabasePathCandidates(); - for (final candidate in candidates) { - try { - _database = await _openDatabase(candidate); - _resolvedDatabasePath = candidate.path; - _usingInMemoryDatabase = false; - break; - } catch (_) { - _database = null; - } - } - if (_database == null && _allowInMemoryFallback) { - try { - final database = sqlite.sqlite3.openInMemory(); - _configureDatabase(database); - _database = database; - _usingInMemoryDatabase = true; - } catch (_) { - _database = null; - _usingInMemoryDatabase = false; - } - } - if (_database == null) { - final candidatePaths = candidates - .map((candidate) => candidate.path) - .toList(growable: false); + final resolvedPath = await _resolveDatabasePath(); + try { + _database = await _openDatabase(resolvedPath); + _resolvedDatabasePath = resolvedPath; + } catch (error) { throw StateError( - 'Durable settings storage unavailable: cannot resolve or open $databaseFileName. Candidates: ${candidatePaths.join(', ')}', + 'Durable settings storage unavailable: failed to open $resolvedPath. Cause: $error', ); } await _migrateLegacyPrefs(); } - Future _openDatabase( - _DatabasePathCandidate candidate, - ) async { - final resolvedPath = candidate.path; + Future _openDatabase(String resolvedPath) async { if (_databaseOpener != null) { final database = await _databaseOpener(resolvedPath); - if (database != null) { - _configureDatabase(database); + if (database == null) { + throw StateError( + 'Durable settings storage unavailable: database opener returned null for $resolvedPath.', + ); } + _configureDatabase(database); return database; } final file = File(resolvedPath); if (!await file.parent.exists()) { - if (candidate.createParentDirectory) { - await file.parent.create(recursive: true); - } else { - throw StateError( - 'Durable settings database directory does not exist: ${file.parent.path}', - ); - } + await file.parent.create(recursive: true); } final database = sqlite.sqlite3.open(file.path); _configureDatabase(database); @@ -273,9 +224,6 @@ class SettingsStore { ); if (existing.isEmpty) { await _writeStoredString(key, legacyValue); - if (_durableStateFileNames.containsKey(key)) { - await _writeDurableStateFile(key, legacyValue); - } } await _prefs!.remove(key); } @@ -328,18 +276,6 @@ class SettingsStore { .toList(growable: false), ), ); - await _writeDurableStateFile( - settingsKey, - recoveredSettings.toJsonString(), - ); - await _writeDurableStateFile( - assistantThreadsKey, - jsonEncode( - recoveredThreads - .map((item) => item.toJson()) - .toList(growable: false), - ), - ); return LegacyRecoveryReport( status: LegacyRecoveryStatus.migrated, sourcePath: source.sourcePath, @@ -364,40 +300,8 @@ class SettingsStore { } Future> _legacyCandidateDirectories() async { - final results = {}; final databasePath = await _resolveDatabasePath(); - final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); - final hasExplicitPaths = - _databasePathResolver != null || _fallbackDirectoryPathResolver != null; - String? defaultSupportRoot; - String? supportPath; - if (!hasExplicitPaths) { - defaultSupportRoot = await _defaultSupportDirectoryPathResolver?.call(); - try { - supportPath = (await getApplicationSupportDirectory()).path; - } catch (_) { - supportPath = null; - } - } - - void addPath(String? path) { - final trimmed = path?.trim() ?? ''; - if (trimmed.isEmpty) { - return; - } - results.add(trimmed); - } - - if (databasePath != null && databasePath.trim().isNotEmpty) { - final directory = File(databasePath).parent.path; - addPath(directory); - } - addPath(fallbackRoot); - addPath(fallbackRoot == null ? null : '$fallbackRoot/xworkmate'); - addPath(defaultSupportRoot); - addPath(supportPath); - addPath(supportPath == null ? null : '$supportPath/xworkmate'); - return results.toList(growable: false); + return [File(databasePath).parent.path]; } Future<_LegacySourceResult> _readLegacySource(String directoryPath) async { @@ -609,121 +513,54 @@ class SettingsStore { } } - Future> _resolveDatabasePathCandidates() async { - final candidates = <_DatabasePathCandidate>[]; - final seen = {}; - - void addPath(String? path, {required bool createParentDirectory}) { - final trimmed = path?.trim() ?? ''; - if (trimmed.isNotEmpty && seen.add(trimmed)) { - candidates.add( - _DatabasePathCandidate( - path: trimmed, - createParentDirectory: createParentDirectory, - ), - ); - } - } - - if (_databasePathResolver != null) { - try { - final resolvedPath = await _databasePathResolver(); - final trimmedPath = resolvedPath?.trim() ?? ''; - if (trimmedPath.isNotEmpty) { - addPath(trimmedPath, createParentDirectory: false); - return candidates; - } - } catch (_) { - // Fall through to default locations. - } - } - - try { - final supportDirectory = await getApplicationSupportDirectory(); - addPath( - '${supportDirectory.path}/xworkmate/$databaseFileName', - createParentDirectory: true, - ); - } catch (_) { - // Continue below to deterministic fallbacks. - } - - try { - final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); - final trimmedFallbackRoot = fallbackRoot?.trim() ?? ''; - if (trimmedFallbackRoot.isNotEmpty) { - addPath( - '$trimmedFallbackRoot/$databaseFileName', - createParentDirectory: true, - ); - } - } catch (_) { - // Continue to default support directory fallback. - } - - try { - final defaultSupportRoot = await _defaultSupportDirectoryPathResolver - ?.call(); - final trimmedDefaultSupportRoot = defaultSupportRoot?.trim() ?? ''; - if (trimmedDefaultSupportRoot.isNotEmpty) { - addPath( - '$trimmedDefaultSupportRoot/$databaseFileName', - createParentDirectory: true, - ); - } - } catch (_) { - // Ignore and fall through. - } - - return candidates; - } - - Future _resolveDatabasePath() async { + Future _resolveDatabasePath() async { final resolved = _resolvedDatabasePath?.trim() ?? ''; if (resolved.isNotEmpty) { return resolved; } - final candidates = await _resolveDatabasePathCandidates(); - if (candidates.isEmpty) { - return null; + final explicitDatabasePath = await _resolvePath(_databasePathResolver); + if (explicitDatabasePath != null) { + return explicitDatabasePath; + } + final fallbackRoot = await _resolvePath(_fallbackDirectoryPathResolver); + if (fallbackRoot != null) { + return '$fallbackRoot/$databaseFileName'; + } + final defaultSupportRoot = await _resolvePath( + _defaultSupportDirectoryPathResolver, + ); + if (defaultSupportRoot != null) { + return '$defaultSupportRoot/$databaseFileName'; + } + try { + final supportDirectory = await getApplicationSupportDirectory(); + return '${supportDirectory.path}/xworkmate/$databaseFileName'; + } catch (_) { + throw StateError( + 'Durable settings storage unavailable: cannot resolve $databaseFileName.', + ); } - return candidates.first.path; } Future _readStoredString(String key) async { - final memoryValue = _memoryStore[key]; - if (memoryValue != null) { - return memoryValue; - } - if (_database != null) { - try { - final result = _database!.select( - 'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1', - [key], - ); - if (result.isNotEmpty) { - final value = result.first['value']; - if (value is String && value.trim().isNotEmpty) { - return value; - } - } - } catch (_) { - // Fall through to durable fallback. - } - } - final durable = await _readDurableStateFile(key); - if (durable != null) { - return durable; + if (_database == null) { + throw StateError('Durable settings storage unavailable: database not initialized.'); } try { - final prefValue = _prefs?.getString(key); - if (prefValue != null && prefValue.trim().isNotEmpty) { - return prefValue; + final result = _database!.select( + 'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1', + [key], + ); + if (result.isEmpty) { + return null; } + final value = result.first['value']; + return value is String && value.trim().isNotEmpty ? value : null; } catch (_) { - // Ignore. + throw StateError( + 'Durable settings storage unavailable: failed to read $key from $_resolvedDatabasePath.', + ); } - return null; } Future _writeStoredString(String key, String value) async { @@ -731,43 +568,40 @@ class SettingsStore { if (trimmed.isEmpty) { return; } - _memoryStore[key] = trimmed; - if (_database != null) { - try { - _database!.execute( - ''' - INSERT INTO $databaseTableName (storage_key, value, updated_at_ms) - VALUES (?, ?, ?) - ON CONFLICT(storage_key) DO UPDATE SET - value = excluded.value, - updated_at_ms = excluded.updated_at_ms - ''', - [key, trimmed, DateTime.now().millisecondsSinceEpoch], - ); - if (_usingInMemoryDatabase) { - await _syncInMemoryStoreToDurableStore(); - } - return; - } catch (_) { - // Fall through to durable file fallback. - } + if (_database == null) { + throw StateError('Durable settings storage unavailable: database not initialized.'); } - if (_usingInMemoryDatabase) { - await _syncInMemoryStoreToDurableStore(); + try { + _database!.execute( + ''' + INSERT INTO $databaseTableName (storage_key, value, updated_at_ms) + VALUES (?, ?, ?) + ON CONFLICT(storage_key) DO UPDATE SET + value = excluded.value, + updated_at_ms = excluded.updated_at_ms + ''', + [key, trimmed, DateTime.now().millisecondsSinceEpoch], + ); + } catch (_) { + throw StateError( + 'Durable settings storage unavailable: failed to write $key to $_resolvedDatabasePath.', + ); } } Future _deleteStoredString(String key) async { - _memoryStore.remove(key); - if (_database != null) { - try { - _database!.execute( - 'DELETE FROM $databaseTableName WHERE storage_key = ?', - [key], - ); - } catch (_) { - // Ignore. - } + if (_database == null) { + throw StateError('Durable settings storage unavailable: database not initialized.'); + } + try { + _database!.execute( + 'DELETE FROM $databaseTableName WHERE storage_key = ?', + [key], + ); + } catch (_) { + throw StateError( + 'Durable settings storage unavailable: failed to delete $key from $_resolvedDatabasePath.', + ); } try { await _prefs?.remove(key); @@ -782,108 +616,10 @@ class SettingsStore { return null; } final databasePath = await _resolveDatabasePath(); - if (databasePath == null || databasePath.trim().isEmpty) { - return null; - } final directory = File(databasePath).parent; - if (!await directory.exists()) { - await directory.create(recursive: true); - } return File('${directory.path}/$fileName'); } - Future _durableStateFileForPath( - String key, - String databasePath, - ) async { - final fileName = _durableStateFileNames[key]; - if (fileName == null) { - return null; - } - final directory = File(databasePath).parent; - if (!await directory.exists()) { - await directory.create(recursive: true); - } - return File('${directory.path}/$fileName'); - } - - Future _readDurableStateFile(String key) async { - final file = await _durableStateFile(key); - if (file == null || !await file.exists()) { - return null; - } - final value = await file.readAsString(); - return value.trim().isEmpty ? null : value; - } - - Future _writeDurableStateFile(String key, String value) async { - final file = await _durableStateFile(key); - if (file == null) { - return; - } - await file.writeAsString(value, flush: true); - } - - Future _syncInMemoryStoreToDurableStore() async { - if (!_usingInMemoryDatabase || _memoryStore.isEmpty) { - return; - } - final candidates = await _resolveDatabasePathCandidates(); - if (candidates.isEmpty) { - return; - } - for (final candidate in candidates) { - sqlite.Database? durableDatabase; - try { - durableDatabase = await _openDatabase(candidate); - if (durableDatabase == null) { - continue; - } - final updatedAtMs = DateTime.now().millisecondsSinceEpoch; - for (final entry in _memoryStore.entries) { - durableDatabase.execute( - ''' - INSERT INTO $databaseTableName (storage_key, value, updated_at_ms) - VALUES (?, ?, ?) - ON CONFLICT(storage_key) DO UPDATE SET - value = excluded.value, - updated_at_ms = excluded.updated_at_ms - ''', - [entry.key, entry.value, updatedAtMs], - ); - final durableFile = await _durableStateFileForPath( - entry.key, - candidate.path, - ); - if (durableFile != null) { - await durableFile.writeAsString(entry.value, flush: true); - } - } - final previousDatabase = _database; - _database = durableDatabase; - _resolvedDatabasePath = candidate.path; - _usingInMemoryDatabase = false; - if (previousDatabase != null && - !identical(previousDatabase, _database)) { - try { - previousDatabase.dispose(); - } catch (_) { - // Ignore close errors during promotion. - } - } - return; - } catch (_) { - if (durableDatabase != null) { - try { - durableDatabase.dispose(); - } catch (_) { - // Ignore close errors while probing candidates. - } - } - } - } - } - Future _deleteDurableStateFile(String key) async { final file = await _durableStateFile(key); if (file == null || !await file.exists()) { @@ -894,9 +630,6 @@ class SettingsStore { Future _deleteLegacyBackupFile() async { final databasePath = await _resolveDatabasePath(); - if (databasePath == null || databasePath.trim().isEmpty) { - return; - } final file = File('${File(databasePath).parent.path}/$stateBackupFileName'); if (await file.exists()) { await file.delete(); @@ -971,6 +704,19 @@ class SettingsStore { json.containsKey('accountUsername') || json.containsKey('assistantExecutionTarget'); } + + Future _resolvePath(Future Function()? resolver) async { + if (resolver == null) { + return null; + } + try { + final resolved = await resolver(); + final trimmed = resolved?.trim() ?? ''; + return trimmed.isEmpty ? null : trimmed; + } catch (_) { + return null; + } + } } class _LegacySourceResult { diff --git a/test/features/ai_gateway_page_suite.dart b/test/features/ai_gateway_page_suite.dart index 3f2462b5..56eea123 100644 --- a/test/features/ai_gateway_page_suite.dart +++ b/test/features/ai_gateway_page_suite.dart @@ -22,11 +22,13 @@ import 'package:xworkmate/theme/app_theme.dart'; import '../test_support.dart'; class _FakeGatewayRuntime extends GatewayRuntime { - _FakeGatewayRuntime() - : super( - store: SecureConfigStore(), - identityStore: DeviceIdentityStore(SecureConfigStore()), - ); + factory _FakeGatewayRuntime() { + final store = createIsolatedTestStore(); + return _FakeGatewayRuntime._(store); + } + + _FakeGatewayRuntime._(SecureConfigStore store) + : super(store: store, identityStore: DeviceIdentityStore(store)); @override Future connectProfile( diff --git a/test/runtime/agent_registry_suite.dart b/test/runtime/agent_registry_suite.dart index 7652d6c4..829551da 100644 --- a/test/runtime/agent_registry_suite.dart +++ b/test/runtime/agent_registry_suite.dart @@ -7,14 +7,17 @@ import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; +import '../test_support.dart'; // Mock GatewayRuntime for testing class MockGatewayRuntime extends GatewayRuntime { - MockGatewayRuntime() - : super( - store: SecureConfigStore(), - identityStore: DeviceIdentityStore(SecureConfigStore()), - ); + factory MockGatewayRuntime() { + final store = createIsolatedTestStore(); + return MockGatewayRuntime._(store); + } + + MockGatewayRuntime._(SecureConfigStore store) + : super(store: store, identityStore: DeviceIdentityStore(store)); final Map _responses = {}; final List> _requests = []; diff --git a/test/runtime/app_controller_codex_bridge_suite.dart b/test/runtime/app_controller_codex_bridge_suite.dart index 5f441e54..bab70dc6 100644 --- a/test/runtime/app_controller_codex_bridge_suite.dart +++ b/test/runtime/app_controller_codex_bridge_suite.dart @@ -13,16 +13,19 @@ import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; +import '../test_support.dart'; const String _manualCodexBridgeSkipReason = 'Disabled by default: reserved for manual validation with a dedicated Codex environment only.'; class _FakeGatewayRuntime extends GatewayRuntime { - _FakeGatewayRuntime({required bool connected}) - : super( - store: SecureConfigStore(), - identityStore: DeviceIdentityStore(SecureConfigStore()), - ) { + factory _FakeGatewayRuntime({required bool connected}) { + final store = createIsolatedTestStore(); + return _FakeGatewayRuntime._(store, connected: connected); + } + + _FakeGatewayRuntime._(SecureConfigStore store, {required bool connected}) + : super(store: store, identityStore: DeviceIdentityStore(store)) { setConnected(connected); } @@ -134,7 +137,7 @@ void main() { 'AppController enables external Codex bridge and registers to gateway', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final gateway = _FakeGatewayRuntime(connected: true); final codex = _FakeCodexRuntime(); final coordinator = RuntimeCoordinator( @@ -201,7 +204,7 @@ void main() { 'AppController keeps bridge running when gateway registration is unavailable', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final gateway = _FakeGatewayRuntime(connected: false); final codex = _FakeCodexRuntime(); final coordinator = RuntimeCoordinator( @@ -258,7 +261,7 @@ void main() { 'AppController preserves built-in mode and does not require external codex binary', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final gateway = _FakeGatewayRuntime(connected: false); final codex = _FakeCodexRuntime(); final coordinator = RuntimeCoordinator( diff --git a/test/runtime/app_controller_desktop_platform_suite.dart b/test/runtime/app_controller_desktop_platform_suite.dart index 85bda946..f7e53077 100644 --- a/test/runtime/app_controller_desktop_platform_suite.dart +++ b/test/runtime/app_controller_desktop_platform_suite.dart @@ -108,12 +108,12 @@ class _ThrowingSecureConfigStore extends SecureConfigStore { : super(enableSecureStorage: false); @override - Future loadGatewayToken() async { + Future loadGatewayToken({int? profileIndex}) async { throw StateError('main store gateway token should not be used'); } @override - Future loadGatewayPassword() async { + Future loadGatewayPassword({int? profileIndex}) async { throw StateError('main store gateway password should not be used'); } diff --git a/test/runtime/code_agent_node_orchestrator_suite.dart b/test/runtime/code_agent_node_orchestrator_suite.dart index 210d03ab..e8bf6a6f 100644 --- a/test/runtime/code_agent_node_orchestrator_suite.dart +++ b/test/runtime/code_agent_node_orchestrator_suite.dart @@ -9,13 +9,16 @@ import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; +import '../test_support.dart'; class _FakeGatewayRuntime extends GatewayRuntime { - _FakeGatewayRuntime() - : super( - store: SecureConfigStore(), - identityStore: DeviceIdentityStore(SecureConfigStore()), - ); + factory _FakeGatewayRuntime() { + final store = createIsolatedTestStore(); + return _FakeGatewayRuntime._(store); + } + + _FakeGatewayRuntime._(SecureConfigStore store) + : super(store: store, identityStore: DeviceIdentityStore(store)); @override Future connectProfile( diff --git a/test/runtime/gateway_runtime_suite.dart b/test/runtime/gateway_runtime_suite.dart index 3c19ad8c..61589d68 100644 --- a/test/runtime/gateway_runtime_suite.dart +++ b/test/runtime/gateway_runtime_suite.dart @@ -10,14 +10,14 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; -import 'package:xworkmate/runtime/secure_config_store.dart'; +import '../test_support.dart'; void main() { test( 'GatewayRuntime uses explicit shared token override for the initial connect handshake', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final runtime = GatewayRuntime( store: store, identityStore: DeviceIdentityStore(store), @@ -64,7 +64,7 @@ void main() { 'GatewayRuntime sends stored operator device token using auth.deviceToken', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final identityStore = DeviceIdentityStore(store); final identity = await identityStore.loadOrCreate(); await store.saveDeviceToken( @@ -109,7 +109,7 @@ void main() { 'GatewayRuntime parses device pairing state and syncs rotated local role tokens', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final identityStore = DeviceIdentityStore(store); final identity = await identityStore.loadOrCreate(); final runtime = GatewayRuntime( @@ -170,7 +170,7 @@ void main() { 'GatewayRuntime does not auto reconnect after non-retryable pairing errors', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final runtime = GatewayRuntime( store: store, identityStore: DeviceIdentityStore(store), @@ -217,7 +217,7 @@ void main() { 'GatewayRuntime clears a stale stored device token after NOT_PAIRED', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); final identityStore = DeviceIdentityStore(store); final identity = await identityStore.loadOrCreate(); await store.saveDeviceToken( diff --git a/test/runtime/mode_switcher_suite.dart b/test/runtime/mode_switcher_suite.dart index 8f374822..f1a3be92 100644 --- a/test/runtime/mode_switcher_suite.dart +++ b/test/runtime/mode_switcher_suite.dart @@ -9,11 +9,12 @@ import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; +import '../test_support.dart'; // Mock GatewayRuntime for testing class MockGatewayRuntime extends GatewayRuntime { factory MockGatewayRuntime() { - final store = SecureConfigStore(); + final store = createIsolatedTestStore(); return MockGatewayRuntime._(store); } diff --git a/test/runtime/runtime_coordinator_suite.dart b/test/runtime/runtime_coordinator_suite.dart index 52f1a732..f03b167a 100644 --- a/test/runtime/runtime_coordinator_suite.dart +++ b/test/runtime/runtime_coordinator_suite.dart @@ -11,13 +11,16 @@ import 'package:xworkmate/runtime/mode_switcher.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; +import '../test_support.dart'; class _FakeGatewayRuntime extends GatewayRuntime { - _FakeGatewayRuntime() - : super( - store: SecureConfigStore(), - identityStore: DeviceIdentityStore(SecureConfigStore()), - ); + factory _FakeGatewayRuntime() { + final store = createIsolatedTestStore(); + return _FakeGatewayRuntime._(store); + } + + _FakeGatewayRuntime._(SecureConfigStore store) + : super(store: store, identityStore: DeviceIdentityStore(store)); GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); final StreamController _events = diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index db654cdd..49929acc 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -180,7 +180,7 @@ void main() { ); test( - 'SecureConfigStore throws when explicit settings directory does not exist', + 'SecureConfigStore auto-creates an explicit settings directory on first install', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -191,33 +191,26 @@ void main() { await tempDirectory.delete(recursive: true); } }); - final existingSecretsDirectory = Directory( - '${tempDirectory.path}/secrets', - ); + final existingSecretsDirectory = Directory('${tempDirectory.path}/secrets'); await existingSecretsDirectory.create(recursive: true); + final explicitSettingsPath = + '${tempDirectory.path}/settings/${SettingsStore.databaseFileName}'; final store = SecureConfigStore( - databasePathResolver: () async => - '${tempDirectory.path}/settings/${SettingsStore.databaseFileName}', - fallbackDirectoryPathResolver: () async => - existingSecretsDirectory.path, + databasePathResolver: () async => explicitSettingsPath, + fallbackDirectoryPathResolver: () async => existingSecretsDirectory.path, ); - await expectLater( - store.loadSettingsSnapshot(), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Durable settings storage unavailable'), - ), - ), - ); + final snapshot = await store.loadSettingsSnapshot(); + + expect(snapshot.accountUsername, SettingsSnapshot.defaults().accountUsername); + expect(await Directory('${tempDirectory.path}/settings').exists(), isTrue); + expect(await File(explicitSettingsPath).exists(), isTrue); }, ); test( - 'SecureConfigStore throws when explicit secrets directory does not exist', + 'SecureConfigStore auto-creates an explicit secrets directory on first install', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -240,16 +233,10 @@ void main() { '${tempDirectory.path}/secrets', ); - await expectLater( - store.saveGatewayToken('token-secret'), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Durable secret storage path does not exist'), - ), - ), - ); + await store.saveGatewayToken('token-secret'); + + expect(await Directory('${tempDirectory.path}/secrets').exists(), isTrue); + expect(await store.loadGatewayToken(), 'token-secret'); }, ); @@ -269,7 +256,6 @@ void main() { '${tempDirectory.path}/plus.svc.xworkmate/xworkmate'; final firstStore = SecureConfigStore( - allowInMemoryFallback: false, databasePathResolver: () async => throw StateError('primary unavailable'), fallbackDirectoryPathResolver: () async => @@ -283,7 +269,6 @@ void main() { await firstStore.saveGatewayToken('fallback-token'); final secondStore = SecureConfigStore( - allowInMemoryFallback: false, databasePathResolver: () async => throw StateError('primary unavailable'), fallbackDirectoryPathResolver: () async => @@ -359,7 +344,7 @@ void main() { ); test( - 'SecureConfigStore persists plain local settings and assistant threads when sqlite is unavailable', + 'SecureConfigStore fails fast and keeps legacy files untouched when sqlite is unavailable', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -371,63 +356,29 @@ void main() { } }); final databasePath = '${tempDirectory.path}/settings.sqlite3'; - final snapshot = SettingsSnapshot.defaults().copyWith( - accountUsername: 'local-user', - assistantLastSessionKey: 'draft:local-1', - ); - const records = [ - AssistantThreadRecord( - sessionKey: 'draft:local-1', - title: '本地线程', - archived: false, - executionTarget: AssistantExecutionTarget.local, - messageViewMode: AssistantMessageViewMode.rendered, - updatedAtMs: 1700000000000, - messages: [ - GatewayChatMessage( - id: 'assistant-1', - role: 'assistant', - text: 'plain local message', - timestampMs: 1700000001000, - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ], - ), - ]; - - final firstStore = SecureConfigStore( - allowInMemoryFallback: true, - databasePathResolver: () async => databasePath, - fallbackDirectoryPathResolver: () async => tempDirectory.path, - databaseOpener: (_) => throw StateError('sqlite unavailable'), - ); - await firstStore.saveSettingsSnapshot(snapshot); - await firstStore.saveAssistantThreadRecords(records); - final settingsFile = File('${tempDirectory.path}/settings-snapshot.json'); final threadsFile = File('${tempDirectory.path}/assistant-threads.json'); - expect(await settingsFile.exists(), isTrue); - expect(await threadsFile.exists(), isTrue); - expect(await settingsFile.readAsString(), contains('local-user')); - expect(await threadsFile.readAsString(), contains('plain local message')); + await settingsFile.writeAsString('{"accountUsername":"local-user"}'); + await threadsFile.writeAsString('[]'); - final secondStore = SecureConfigStore( - allowInMemoryFallback: true, + final firstStore = SecureConfigStore( databasePathResolver: () async => databasePath, fallbackDirectoryPathResolver: () async => tempDirectory.path, databaseOpener: (_) => throw StateError('sqlite unavailable'), ); - final loadedSnapshot = await secondStore.loadSettingsSnapshot(); - final loadedThreads = await secondStore.loadAssistantThreadRecords(); - expect(loadedSnapshot.accountUsername, 'local-user'); - expect(loadedSnapshot.assistantLastSessionKey, 'draft:local-1'); - expect(loadedThreads, hasLength(1)); - expect(loadedThreads.single.messages.single.text, 'plain local message'); + await expectLater( + firstStore.loadSettingsSnapshot(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('sqlite unavailable'), + ), + ), + ); + expect(await settingsFile.exists(), isTrue); + expect(await threadsFile.exists(), isTrue); }, ); @@ -894,7 +845,7 @@ void main() { ); test( - 'SecureConfigStore restores assistant state from durable files when sqlite entries are missing', + 'SecureConfigStore restart keeps database state and legacy session files untouched', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -940,10 +891,10 @@ void main() { await store.saveSettingsSnapshot(snapshot); await store.saveAssistantThreadRecords(records); - - final database = sqlite.sqlite3.open(databasePath); - addTearDown(database.dispose); - database.execute('DELETE FROM ${SettingsStore.databaseTableName}'); + final settingsFile = File('${tempDirectory.path}/settings-snapshot.json'); + final threadsFile = File('${tempDirectory.path}/assistant-threads.json'); + await settingsFile.writeAsString('legacy-settings-snapshot', flush: true); + await threadsFile.writeAsString('legacy-assistant-threads', flush: true); final recoveredStore = SecureConfigStore( databasePathResolver: () async => databasePath, @@ -958,6 +909,8 @@ void main() { expect(recoveredRecords, hasLength(1)); expect(recoveredRecords.first.sessionKey, 'draft:backup-1'); expect(recoveredRecords.first.messages.single.text, 'backup message'); + expect(await settingsFile.readAsString(), 'legacy-settings-snapshot'); + expect(await threadsFile.readAsString(), 'legacy-assistant-threads'); }, ); @@ -1060,7 +1013,19 @@ void main() { 'SecureConfigStore clears gateway token without touching snapshot', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-clear-token-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + databasePathResolver: () async => + '${tempDirectory.path}/${SettingsStore.databaseFileName}', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); await store.saveGatewayToken('token-secret'); expect(await store.loadGatewayToken(), 'token-secret'); diff --git a/test/test_support.dart b/test/test_support.dart index e0679428..da87db34 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -10,6 +10,23 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; +SecureConfigStore createIsolatedTestStore({bool enableSecureStorage = true}) { + final testRoot = Directory.systemTemp.createTempSync( + 'xworkmate-store-test-', + ); + addTearDown(() async { + if (await testRoot.exists()) { + await testRoot.delete(recursive: true); + } + }); + return SecureConfigStore( + enableSecureStorage: enableSecureStorage, + databasePathResolver: () async => + '${testRoot.path}/${SettingsStore.databaseFileName}', + fallbackDirectoryPathResolver: () async => testRoot.path, + ); +} + Future createTestController( WidgetTester tester, { DesktopPlatformService? desktopPlatformService,