diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index cafa2e04..efd9a6e1 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -1,6 +1,9 @@ +import '../../theme/theme_definition.dart'; import 'extension_type.dart'; class ExtensionManifest { + static const typeTheme = ExtensionType.theme; + final String id; final String name; final String version; @@ -11,6 +14,13 @@ class ExtensionManifest { final String? icon; final String? description; final String? installPath; + final String? downloadUrl; + final String? sha256Checksum; + final String? author; + final String? homepage; + final String? license; + final String? preview; + final List tags; const ExtensionManifest({ required this.id, @@ -23,20 +33,62 @@ class ExtensionManifest { this.icon, this.description, this.installPath, + this.downloadUrl, + this.sha256Checksum, + this.author, + this.homepage, + this.license, + this.preview, + this.tags = const [], }); + /// Maps a registry [ThemeDefinition] into marketplace field names. + factory ExtensionManifest.fromThemeDefinition( + ThemeDefinition definition, { + String downloadUrl = '', + String sha256Checksum = '', + ExtensionType type = typeTheme, + }) { + final metadata = definition.metadata; + return ExtensionManifest( + id: definition.id, + name: definition.name, + publisher: metadata?.author ?? 'Unknown', + type: type, + version: metadata?.version ?? '0.0.0', + engines: const {'querya_desktop': '^0.4.7'}, + downloadUrl: downloadUrl, + sha256Checksum: sha256Checksum.isNotEmpty + ? sha256Checksum + : (definition.contentHash ?? ''), + author: metadata?.author, + description: metadata?.description, + homepage: metadata?.homepage, + license: metadata?.license, + preview: metadata?.preview, + tags: metadata?.tags ?? const [], + ); + } + factory ExtensionManifest.fromJson(Map json, {String? installPath}) { return ExtensionManifest( id: json['id'] as String, name: json['name'] as String, - version: json['version'] as String, - publisher: json['publisher'] as String, - type: ExtensionType.fromString(json['type'] as String), + version: json['version'] as String? ?? '0.0.0', + publisher: json['publisher'] as String? ?? json['author'] as String? ?? 'Unknown', + type: ExtensionType.fromString(json['type'] as String? ?? ''), engines: Map.from(json['engines'] as Map? ?? {}), main: json['main'] as String?, icon: json['icon'] as String?, description: json['description'] as String?, installPath: installPath, + downloadUrl: json['downloadUrl'] as String?, + sha256Checksum: json['sha256Checksum'] as String? ?? json['sha256'] as String?, + author: json['author'] as String?, + homepage: json['homepage'] as String?, + license: json['license'] as String?, + preview: json['preview'] as String?, + tags: List.from(json['tags'] as List? ?? []), ); } @@ -51,6 +103,13 @@ class ExtensionManifest { if (main != null) 'main': main, if (icon != null) 'icon': icon, if (description != null) 'description': description, + if (downloadUrl != null) 'downloadUrl': downloadUrl, + if (sha256Checksum != null) 'sha256Checksum': sha256Checksum, + if (author != null) 'author': author, + if (homepage != null) 'homepage': homepage, + if (license != null) 'license': license, + if (preview != null) 'preview': preview, + if (tags.isNotEmpty) 'tags': tags, }; } } diff --git a/lib/core/market/extension_manifest.dart b/lib/core/market/extension_manifest.dart index be1edec8..4cbed8b3 100644 --- a/lib/core/market/extension_manifest.dart +++ b/lib/core/market/extension_manifest.dart @@ -1,65 +1,2 @@ -import '../theme/theme_definition.dart'; +export 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; -/// Marketplace listing model (future `MarketplaceClient` response shape). -/// -/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md). -class ExtensionManifest { - const ExtensionManifest({ - required this.id, - required this.name, - required this.type, - required this.version, - required this.downloadUrl, - required this.sha256Checksum, - this.author, - this.description, - this.homepage, - this.license, - this.preview, - this.tags = const [], - }); - - static const typeTheme = 'theme'; - - final String id; - final String name; - final String type; - final String version; - final String downloadUrl; - final String sha256Checksum; - final String? author; - final String? description; - final String? homepage; - final String? license; - final String? preview; - final List tags; - - /// Maps a registry [ThemeDefinition] into marketplace field names. - /// - /// [downloadUrl] and [sha256Checksum] are required for remote install (TP-F4); - /// pass empty strings when building a local-only listing stub. - factory ExtensionManifest.fromThemeDefinition( - ThemeDefinition definition, { - String downloadUrl = '', - String sha256Checksum = '', - String type = typeTheme, - }) { - final metadata = definition.metadata; - return ExtensionManifest( - id: definition.id, - name: definition.name, - type: type, - version: metadata?.version ?? '0.0.0', - downloadUrl: downloadUrl, - sha256Checksum: sha256Checksum.isNotEmpty - ? sha256Checksum - : (definition.contentHash ?? ''), - author: metadata?.author, - description: metadata?.description, - homepage: metadata?.homepage, - license: metadata?.license, - preview: metadata?.preview, - tags: metadata?.tags ?? const [], - ); - } -} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart new file mode 100644 index 00000000..03d7a7e9 --- /dev/null +++ b/lib/core/market/http_marketplace_repository.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'marketplace_repository.dart'; + +class MarketplaceException implements Exception { + MarketplaceException(this.message); + final String message; + @override + String toString() => 'MarketplaceException: $message'; +} + +/// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. +/// +/// Implements secure downloading with SHA256 checksum verification and safe +/// archive extraction preventing Path Traversal and Zip Bomb vulnerabilities (Issue #242). +class HttpMarketplaceRepository implements MarketplaceRepository { + HttpMarketplaceRepository({ + this.baseUrl = 'http://localhost:8000/api/v1', + http.Client? client, + }) : _client = client ?? http.Client(); + + final String baseUrl; + final http.Client _client; + + @override + Future> getTrending({ExtensionType? type}) async { + final uri = Uri.parse('$baseUrl/extensions/trending').replace( + queryParameters: type != null ? {'type': type.value} : null, + ); + final response = await _client.get(uri).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException('Failed to load trending extensions (HTTP ${response.statusCode})'); + } + final List data = jsonDecode(response.body) as List; + return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); + } + + @override + Future> search(String query, {ExtensionType? type}) async { + final uri = Uri.parse('$baseUrl/extensions/search').replace( + queryParameters: { + 'q': query.trim(), + if (type != null) 'type': type.value, + }, + ); + final response = await _client.get(uri).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException('Search failed (HTTP ${response.statusCode})'); + } + final List data = jsonDecode(response.body) as List; + return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); + } + + @override + Future download(String url, {void Function(double)? onProgress}) async { + final uri = Uri.tryParse(url); + if (uri == null) { + throw MarketplaceException('Invalid download URL: $url'); + } + + final request = http.Request('GET', uri); + final response = await _client.send(request).timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) { + throw MarketplaceException('Download failed with HTTP status ${response.statusCode}'); + } + + final contentLength = response.contentLength ?? 0; + int received = 0; + + final tmpDir = Directory.systemTemp; + final file = File(p.join(tmpDir.path, 'querya_ext_${DateTime.now().millisecondsSinceEpoch}.zip')); + final sink = file.openWrite(); + + try { + await response.stream.forEach((chunk) { + sink.add(chunk); + received += chunk.length; + if (contentLength > 0 && onProgress != null) { + onProgress(received / contentLength); + } + }); + } finally { + await sink.close(); + } + + if (onProgress != null && contentLength == 0) { + onProgress(1.0); + } + + return file; + } + + @override + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }) async { + final downloadUrl = manifest.downloadUrl; + if (downloadUrl == null || downloadUrl.trim().isEmpty) { + throw MarketplaceException('Extension manifest is missing downloadUrl'); + } + + // Step 1: Download archive with progress reporting (up to 80% of total progress) + final archiveFile = await download( + downloadUrl, + onProgress: (p) => onProgress?.call(p * 0.8), + ); + + try { + // Step 2: SHA-256 Integrity Verification (Critical Security Check) + if (manifest.sha256Checksum != null && manifest.sha256Checksum!.trim().isNotEmpty) { + final bytes = await archiveFile.readAsBytes(); + final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); + final expectedSha256 = manifest.sha256Checksum!.trim().toLowerCase(); + if (actualSha256 != expectedSha256) { + throw MarketplaceException( + 'SHA256 checksum mismatch for "${manifest.id}". Expected: $expectedSha256, Actual: $actualSha256. Installation aborted.', + ); + } + } + + onProgress?.call(0.85); + + // Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242) + final bytes = await archiveFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, manifest.id)); + if (!await extDir.exists()) { + await extDir.create(recursive: true); + } + + final extDirPath = p.normalize(extDir.path); + + for (final file in archive) { + final filename = file.name; + // Check for Path Traversal attempts + if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) { + throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"'); + } + + final targetPath = p.normalize(p.join(extDirPath, filename)); + if (!targetPath.startsWith(extDirPath)) { + throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"'); + } + + if (file.isFile) { + final outFile = File(targetPath); + await outFile.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } + + onProgress?.call(0.95); + + // Step 4: Write/Update manifest.json in the extension directory + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString(encoder.convert(manifest.toJson())); + + // Step 5: Reload local extension registry + await LocalExtensionRegistry.instance.reload(); + onProgress?.call(1.0); + } finally { + if (await archiveFile.exists()) { + await archiveFile.delete(); + } + } + } + + @override + Future uninstall(String extensionId) async { + final manifest = LocalExtensionRegistry.instance.manifests + .where((e) => e.id == extensionId) + .firstOrNull; + + if (manifest != null && manifest.installPath != null) { + final extDir = Directory(manifest.installPath!); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } else { + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, extensionId)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } + + await LocalExtensionRegistry.instance.reload(); + } +} diff --git a/lib/core/market/marketplace_client.dart b/lib/core/market/marketplace_client.dart index 1c450cb0..828bbf60 100644 --- a/lib/core/market/marketplace_client.dart +++ b/lib/core/market/marketplace_client.dart @@ -1,4 +1,5 @@ import 'extension_manifest.dart'; +import 'marketplace_repository.dart'; /// Future marketplace API client (mockable until backend exists). /// @@ -10,24 +11,19 @@ abstract class MarketplaceClient { }); } -/// In-memory placeholder for local development and tests. +/// In-memory placeholder for local development and tests that delegates to [MarketplaceRepository]. class MockMarketplaceClient implements MarketplaceClient { MockMarketplaceClient({List? seed}) - : _items = List.from(seed ?? const []); + : _repository = MockMarketplaceRepository(seed: seed); - final List _items; + final MockMarketplaceRepository _repository; @override Future> searchExtensions({ required String query, String? type, }) async { - final normalized = query.trim().toLowerCase(); - return _items.where((item) { - if (type != null && item.type != type) return false; - if (normalized.isEmpty) return true; - return item.name.toLowerCase().contains(normalized) || - item.id.toLowerCase().contains(normalized); - }).toList(growable: false); + return _repository.search(query); } } + diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart new file mode 100644 index 00000000..421ac9b6 --- /dev/null +++ b/lib/core/market/marketplace_repository.dart @@ -0,0 +1,306 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; + +export 'http_marketplace_repository.dart'; + +/// Abstract repository contract for Marketplace operations (Block B). +/// +/// See [docs/market-tech.md] and Block B specification. +abstract class MarketplaceRepository { + static MarketplaceRepository instance = MockMarketplaceRepository(); + + /// Returns trending and recommended marketplace extensions. + Future> getTrending({ExtensionType? type}); + + /// Searches the marketplace catalog by query string and optional type filter. + Future> search(String query, {ExtensionType? type}); + + /// Simulates downloading an archive from [url] with progress reporting. + Future download(String url, {void Function(double)? onProgress}); + + /// Installs an extension by downloading, validating, and registering it locally. + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }); + + /// Uninstalls a locally installed extension by ID. + Future uninstall(String extensionId); +} + +/// In-memory mock implementation for local UI development and testing (Release 0.4.8). +class MockMarketplaceRepository implements MarketplaceRepository { + MockMarketplaceRepository({List? seed}) + : _items = List.from(seed ?? _defaultMocks); + + final List _items; + + static final List _defaultMocks = [ + const ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Full support for ClickHouse databases including Dictionaries, Materialized Views, and real-time query metrics.', + downloadUrl: 'https://cdn.queryahub.com/extensions/clickhouse-driver-1.0.0.zip', + sha256Checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + homepage: 'https://queryahub.com/drivers/clickhouse', + license: 'MIT', + tags: ['database', 'clickhouse', 'olap', 'official'], + ), + const ExtensionManifest( + id: 'community.redis-driver', + name: 'Redis Driver', + version: '0.9.5', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Connect to Redis instances, visualize key-value storage, inspect Pub/Sub channels, and edit JSON documents.', + downloadUrl: 'https://cdn.queryahub.com/extensions/redis-driver-0.9.5.zip', + sha256Checksum: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4', + homepage: 'https://github.com/queryahub/redis-driver', + license: 'Apache-2.0', + tags: ['database', 'redis', 'nosql', 'key-value'], + ), + const ExtensionManifest( + id: 'queryahub.cyberpunk-neon', + name: 'Cyberpunk Neon', + version: '1.2.0', + publisher: 'QueryaHub', + type: ExtensionType.theme, + engines: {'querya_desktop': '^0.4.7'}, + main: 'theme.json', + description: 'Vibrant neon color scheme inspired by Cyberpunk 2077 with glowing syntax highlighting and dark futuristic workbench.', + downloadUrl: 'https://cdn.queryahub.com/themes/cyberpunk-neon-1.2.0.zip', + sha256Checksum: 'a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0', + homepage: 'https://queryahub.com/themes/cyberpunk', + license: 'MIT', + tags: ['theme', 'dark', 'cyberpunk', 'neon', 'official'], + ), + const ExtensionManifest( + id: 'community.nord-theme', + name: 'Nord Theme', + version: '0.8.2', + publisher: 'Community', + type: ExtensionType.theme, + engines: {'querya_desktop': '^0.4.7'}, + main: 'theme.json', + description: 'An arctic, north-bluish clean and elegant color palette designed for focused, distraction-free SQL coding.', + downloadUrl: 'https://cdn.queryahub.com/themes/nord-theme-0.8.2.zip', + sha256Checksum: 'b2c3d4e5f6a10718293a4b5c6d7e8f90123456789abcdef0123456789abcdef1', + homepage: 'https://github.com/arcticicestudio/nord', + license: 'MIT', + tags: ['theme', 'dark', 'nord', 'arctic', 'minimal'], + ), + const ExtensionManifest( + id: 'queryahub.mongodb-driver', + name: 'MongoDB Driver', + version: '1.1.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Explore MongoDB collections, execute aggregation pipelines, and view BSON documents in an interactive tree view.', + downloadUrl: 'https://cdn.queryahub.com/extensions/mongodb-driver-1.1.0.zip', + sha256Checksum: 'c3d4e5f6a1b20718293a4b5c6d7e8f90123456789abcdef0123456789abcdef2', + homepage: 'https://queryahub.com/drivers/mongodb', + license: 'MIT', + tags: ['database', 'mongodb', 'nosql', 'bson', 'official'], + ), + const ExtensionManifest( + id: 'community.postgres-exporter', + name: 'PostgreSQL Schema Exporter', + version: '0.5.0', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Export complex PostgreSQL schemas to dbdiagram.io, Mermaid ERD, and DDL scripts with one click.', + downloadUrl: 'https://cdn.queryahub.com/extensions/postgres-exporter-0.5.0.zip', + sha256Checksum: 'd4e5f6a1b2c30718293a4b5c6d7e8f90123456789abcdef0123456789abcdef3', + homepage: 'https://github.com/querya-community/postgres-exporter', + license: 'MIT', + tags: ['postgresql', 'schema', 'export', 'erd', 'mermaid'], + ), + ]; + + @override + Future> getTrending({ExtensionType? type}) async { + await Future.delayed(const Duration(milliseconds: 150)); + return _items.where((item) { + if (type != null && item.type != type) return false; + return true; + }).toList(growable: false); + } + + @override + Future> search(String query, {ExtensionType? type}) async { + await Future.delayed(const Duration(milliseconds: 150)); + final normalized = query.trim().toLowerCase(); + return _items.where((item) { + if (type != null && item.type != type) return false; + if (normalized.isEmpty) return true; + return item.name.toLowerCase().contains(normalized) || + item.id.toLowerCase().contains(normalized) || + item.publisher.toLowerCase().contains(normalized) || + (item.description?.toLowerCase().contains(normalized) ?? false) || + item.tags.any((tag) => tag.toLowerCase().contains(normalized)); + }).toList(growable: false); + } + + @override + Future download(String url, {void Function(double)? onProgress}) async { + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 80)); + onProgress?.call(i / 10.0); + } + final tmpDir = Directory.systemTemp; + final file = File(p.join(tmpDir.path, 'querya_ext_mock_${DateTime.now().millisecondsSinceEpoch}.zip')); + await file.writeAsString('mock archive payload'); + return file; + } + + @override + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }) async { + // Simulate download & verification progress + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + onProgress?.call(i / 10.0); + } + + // Persist to local extensions directory so LocalExtensionRegistry discovers it + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, manifest.id)); + if (!await extDir.exists()) { + await extDir.create(recursive: true); + } + + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString(encoder.convert(manifest.toJson())); + + // If installing a theme in mock mode, generate a working theme.json so it can be applied in settings + if (manifest.type == ExtensionType.theme) { + final themeFile = File(p.join(extDir.path, 'theme.json')); + final themeContent = _getMockThemeContent(manifest); + await themeFile.writeAsString(themeContent); + } + + // Reload local registry + await LocalExtensionRegistry.instance.reload(); + } + + String _getMockThemeContent(ExtensionManifest manifest) { + if (manifest.id.contains('nord')) { + return '''{ + "name": "Nord Theme", + "type": "dark", + "colors": { + "activityBar.background": "#2e3440", + "statusBar.background": "#2e3440", + "sideBar.background": "#3b4252", + "sideBar.foreground": "#d8dee9", + "tab.activeBackground": "#434c5e", + "panel.background": "#3b4252", + "input.background": "#434c5e", + "editor.background": "#2e3440", + "editor.foreground": "#eceff4", + "editor.selectionBackground": "#434c5e88", + "editorLineNumber.foreground": "#4c566a", + "focusBorder": "#88c0d0" + }, + "tokenColors": [ + { + "name": "Keywords", + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#81a1c1", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#a3be8c" } + }, + { + "name": "Numbers", + "scope": ["constant.numeric"], + "settings": { "foreground": "#b48ead" } + } + ] +}'''; + } + return '''{ + "name": "\${manifest.name}", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "focusBorder": "#00f5ff" + }, + "tokenColors": [ + { + "name": "Keywords", + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#ff2a6d", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#fcee09" } + }, + { + "name": "Functions", + "scope": ["entity.name.function"], + "settings": { "foreground": "#00f5ff" } + } + ] +}'''; + } + + @override + Future uninstall(String extensionId) async { + await Future.delayed(const Duration(milliseconds: 150)); + + // Check local extension registry first + final manifest = LocalExtensionRegistry.instance.manifests + .where((e) => e.id == extensionId) + .firstOrNull; + + if (manifest != null && manifest.installPath != null) { + final extDir = Directory(manifest.installPath!); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } else { + // Fallback: check standard extensions directory by ID + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, extensionId)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } + + await LocalExtensionRegistry.instance.reload(); + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index eebba457..1daae918 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -370,6 +370,30 @@ class LocalDb { return id; } + /// Atomically updates an existing connection row in SQLite and its secrets in the secure store. + Future updateConnection(ConnectionRow row) async { + if (row.id == null) { + throw ArgumentError('ConnectionRow.id cannot be null when calling updateConnection'); + } + final db = await _open(); + await db.transaction((txn) async { + final count = await txn.update( + 'connections', + row.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + if (count == 0) { + throw ArgumentError('No connection found with id ${row.id}'); + } + }); + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: row.password, + connectionString: row.connectionString, + ); + } + Future removeConnection(int id) async { await ConnectionSecretsStore.deleteForConnection(id); final db = await _open(); diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 323847cb..e54f2e46 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; -import 'package:querya_desktop/core/extensions/models/extension_type.dart'; -import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; void showExtensionManagerDialog(material.BuildContext context) { @@ -26,30 +27,73 @@ class _ExtensionManagerContent extends material.StatefulWidget { class _ExtensionManagerContentState extends material.State<_ExtensionManagerContent> { int _tabIndex = 0; + List _installed = []; + List _marketplace = []; + bool _loading = true; + final Map _installingProgress = {}; - final List _installedMocks = [ - const ExtensionManifest( - id: 'queryahub.clickhouse-driver', - name: 'ClickHouse Driver', - version: '1.0.0', - publisher: 'QueryaHub', - type: ExtensionType.databaseDriver, - engines: {'querya_desktop': '^0.4.7'}, - description: 'Full support for ClickHouse databases including Dictionaries and Materialized Views.', - ), - ]; + @override + void initState() { + super.initState(); + _loadData(); + } - final List _marketMocks = [ - const ExtensionManifest( - id: 'community.redis-driver', - name: 'Redis Driver', - version: '0.9.5', - publisher: 'Community', - type: ExtensionType.databaseDriver, - engines: {'querya_desktop': '^0.4.7'}, - description: 'Connect to Redis instances and visualize key-value storage.', - ), - ]; + Future _loadData() async { + setState(() => _loading = true); + await LocalExtensionRegistry.instance.load(); + final installed = LocalExtensionRegistry.instance.manifests; + final market = await MarketplaceRepository.instance.getTrending(); + if (mounted) { + setState(() { + _installed = installed; + _marketplace = market; + _loading = false; + }); + } + } + + Future _onSearchChanged(String query) async { + if (query.trim().isEmpty) { + final market = await MarketplaceRepository.instance.getTrending(); + if (mounted) setState(() => _marketplace = market); + } else { + final market = await MarketplaceRepository.instance.search(query); + if (mounted) setState(() => _marketplace = market); + } + } + + Future _installExtension(ExtensionManifest manifest) async { + setState(() => _installingProgress[manifest.id] = 0.01); + try { + await MarketplaceRepository.instance.install( + manifest, + onProgress: (progress) { + if (mounted) { + setState(() => _installingProgress[manifest.id] = progress); + } + }, + ); + if (mounted) { + setState(() { + _installingProgress.remove(manifest.id); + _installed = LocalExtensionRegistry.instance.manifests; + }); + } + } catch (e) { + if (mounted) { + setState(() => _installingProgress.remove(manifest.id)); + } + } + } + + Future _uninstallExtension(ExtensionManifest manifest) async { + await MarketplaceRepository.instance.uninstall(manifest.id); + if (mounted) { + setState(() { + _installed = LocalExtensionRegistry.instance.manifests; + }); + } + } @override material.Widget build(material.BuildContext context) { @@ -84,16 +128,19 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont mainAxisAlignment: material.MainAxisAlignment.spaceBetween, crossAxisAlignment: material.CrossAxisAlignment.center, children: [ - material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Extensions').large().semiBold().foreground(), - const material.SizedBox(height: 6), - const Text('Manage local and marketplace extensions') - .muted() - .small(), - ], + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const Text('Manage local and marketplace extensions') + .muted() + .small(), + ], + ), ), + const material.SizedBox(width: 16), PrimaryButton( onPressed: () => material.Navigator.of(context).pop(), child: const Text('Close'), @@ -104,12 +151,12 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 24.0, vertical: 8.0), - child: material.Row( + child: material.Wrap( + spacing: 8, + runSpacing: 8, children: [ - _buildTabButton(0, 'Installed'), - const material.SizedBox(width: 8), + _buildTabButton(0, 'Installed', count: _installed.length), _buildTabButton(1, 'Marketplace'), - const material.SizedBox(width: 8), _buildTabButton(2, 'Updates'), ], ), @@ -133,46 +180,115 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont ); } - material.Widget _buildTabButton(int index, String label) { + material.Widget _buildTabButton(int index, String label, {int? count}) { final isSelected = _tabIndex == index; + final displayLabel = count != null ? '$label ($count)' : label; return SecondaryButton( onPressed: () => setState(() => _tabIndex = index), child: material.Text( - label, + displayLabel, style: material.TextStyle( color: isSelected ? Theme.of(context).colorScheme.primary : null, + fontWeight: isSelected ? material.FontWeight.w600 : material.FontWeight.w400, ), ), ); } material.Widget _buildInstalledTab() { + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + if (_installed.isEmpty) { + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('No extensions installed yet. Explore the Marketplace tab to get started!'), + ), + ); + } return material.ListView.separated( padding: const material.EdgeInsets.all(24), - itemCount: _installedMocks.length, + itemCount: _installed.length, separatorBuilder: (_, __) => const material.SizedBox(height: 16), - itemBuilder: (ctx, i) => ExtensionCard( - manifest: _installedMocks[i], - isInstalled: true, - onUninstall: () {}, - ), + itemBuilder: (ctx, i) { + final manifest = _installed[i]; + final isInstalling = _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: true, + isInstalling: isInstalling, + installProgress: progress, + onUninstall: () => _uninstallExtension(manifest), + ); + }, ); } material.Widget _buildMarketplaceTab() { - return material.ListView.separated( - padding: const material.EdgeInsets.all(24), - itemCount: _marketMocks.length, - separatorBuilder: (_, __) => const material.SizedBox(height: 16), - itemBuilder: (ctx, i) => ExtensionCard( - manifest: _marketMocks[i], - isInstalled: false, - onInstall: () {}, - ), + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + return material.Column( + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8), + child: TextField( + placeholder: const material.Text('Search extensions by name, tag, or description...'), + onChanged: _onSearchChanged, + features: const [ + InputFeature.leading( + Padding( + padding: material.EdgeInsets.only(right: 8), + child: material.Icon(material.Icons.search_rounded, size: 18), + ), + ), + ], + ), + ), + material.Expanded( + child: _marketplace.isEmpty + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('No extensions found matching your search.'), + ), + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _marketplace.length, + separatorBuilder: (_, __) => const material.SizedBox(height: 16), + itemBuilder: (ctx, i) { + final manifest = _marketplace[i]; + final isInstalled = _installed.any((e) => e.id == manifest.id); + final isInstalling = _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: isInstalled, + isInstalling: isInstalling, + installProgress: progress, + onInstall: () => _installExtension(manifest), + onUninstall: () => _uninstallExtension(manifest), + ); + }, + ), + ), + ], ); } material.Widget _buildUpdatesTab() { - return const material.Center(child: material.Text('No updates available.')); + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('All installed extensions are up to date!'), + ), + ); } } diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart index 5f5fc45e..2d25b488 100644 --- a/lib/features/extensions/presentation/widgets/extension_card.dart +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; class ExtensionCard extends material.StatelessWidget { @@ -8,6 +9,8 @@ class ExtensionCard extends material.StatelessWidget { required this.manifest, required this.isInstalled, this.hasUpdate = false, + this.isInstalling = false, + this.installProgress, this.onInstall, this.onUninstall, this.onUpdate, @@ -16,6 +19,8 @@ class ExtensionCard extends material.StatelessWidget { final ExtensionManifest manifest; final bool isInstalled; final bool hasUpdate; + final bool isInstalling; + final double? installProgress; final material.VoidCallback? onInstall; final material.VoidCallback? onUninstall; final material.VoidCallback? onUpdate; @@ -71,6 +76,17 @@ class ExtensionCard extends material.StatelessWidget { maxLines: 2, overflow: material.TextOverflow.ellipsis, ).muted(), + if (manifest.tags.isNotEmpty) ...[ + const material.SizedBox(height: 10), + material.Wrap( + spacing: 6, + runSpacing: 4, + children: manifest.tags + .take(5) + .map((tag) => _buildTagBadge(theme, tag)) + .toList(), + ), + ], ], ), ), @@ -79,7 +95,7 @@ class ExtensionCard extends material.StatelessWidget { mainAxisAlignment: material.MainAxisAlignment.center, crossAxisAlignment: material.CrossAxisAlignment.end, children: [ - if (isInstalled && hasUpdate) + if (isInstalled && hasUpdate && !isInstalling) material.Padding( padding: const material.EdgeInsets.only(bottom: 8.0), child: PrimaryButton( @@ -87,12 +103,34 @@ class ExtensionCard extends material.StatelessWidget { child: const Text('Update'), ), ), - if (isInstalled) + if (isInstalling) + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.end, + children: [ + material.SizedBox( + width: 110, + child: material.LinearProgressIndicator( + value: installProgress, + backgroundColor: theme.muted, + color: theme.primary, + minHeight: 6, + borderRadius: material.BorderRadius.circular(3), + ), + ), + const material.SizedBox(height: 6), + Text(installProgress != null + ? 'Installing ${(installProgress! * 100).toInt()}%' + : 'Installing...') + .muted() + .small(), + ], + ) + else if (isInstalled) SecondaryButton( onPressed: onUninstall, child: const Text('Uninstall'), - ), - if (!isInstalled) + ) + else PrimaryButton( onPressed: onInstall, child: const Text('Install'), @@ -113,10 +151,31 @@ class ExtensionCard extends material.StatelessWidget { borderRadius: material.BorderRadius.circular(radius), ), child: material.Icon( - material.Icons.extension_rounded, + manifest.type == ExtensionType.theme + ? material.Icons.palette_outlined + : material.Icons.extension_rounded, size: 24, color: theme.mutedForeground, ), ); } + + material.Widget _buildTagBadge(ColorScheme theme, String tag) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + tag, + style: material.TextStyle( + fontSize: 11, + color: theme.mutedForeground, + fontWeight: material.FontWeight.w500, + ), + ), + ); + } } + diff --git a/pubspec.yaml b/pubspec.yaml index e2585775..bf4086c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: flutter_secure_storage: ^9.2.4 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 + archive: ^4.0.9 dev_dependencies: flutter_test: diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart new file mode 100644 index 00000000..32e9292a --- /dev/null +++ b/test/core/market/marketplace_repository_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +void main() { + group('MockMarketplaceRepository', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_market_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('getTrending returns default seed items', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + expect(trending, isNotEmpty); + expect(trending.any((e) => e.id == 'queryahub.clickhouse-driver'), isTrue); + expect(trending.any((e) => e.id == 'queryahub.cyberpunk-neon'), isTrue); + }); + + test('search filters by name, id, description, and tags', () async { + final repo = MockMarketplaceRepository(); + + final byName = await repo.search('ClickHouse'); + expect(byName.length, 1); + expect(byName.first.id, 'queryahub.clickhouse-driver'); + + final byTag = await repo.search('neon'); + expect(byTag.length, 1); + expect(byTag.first.id, 'queryahub.cyberpunk-neon'); + + final empty = await repo.search('nonexistent_extension_query_12345'); + expect(empty, isEmpty); + }); + + test('install writes manifest to disk and reloads LocalExtensionRegistry', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); + + final progressValues = []; + await repo.install(target, onProgress: (p) => progressValues.add(p)); + + expect(progressValues, isNotEmpty); + expect(progressValues.last, 1.0); + + expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); + + final extDir = Directory(p.join(tempDir.path, target.id)); + expect(await extDir.exists(), isTrue); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + }); + + test('uninstall removes directory and updates LocalExtensionRegistry', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); + + await repo.install(target); + expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); + + await repo.uninstall(target.id); + expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isFalse); + expect(await Directory(p.join(tempDir.path, target.id)).exists(), isFalse); + }); + + test('install theme creates theme.json in extension directory', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.cyberpunk-neon'); + + await repo.install(target); + + final extDir = Directory(p.join(tempDir.path, target.id)); + expect(await extDir.exists(), isTrue); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'theme.json')).exists(), isTrue); + }); + }); + + group('HttpMarketplaceRepository', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_http_market_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('getTrending fetches from backend API', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/v1/extensions/trending'); + return http.Response(jsonEncode([ + { + 'id': 'test.extension', + 'name': 'Test Ext', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + } + ]), 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final trending = await repo.getTrending(); + expect(trending.length, 1); + expect(trending.first.id, 'test.extension'); + }); + + test('search queries backend API', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/v1/extensions/search'); + expect(request.url.queryParameters['q'], 'sql'); + return http.Response(jsonEncode([ + { + 'id': 'test.sql', + 'name': 'SQL Tools', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'databaseDriver', + 'engines': {'querya_desktop': '*'}, + } + ]), 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final results = await repo.search('sql'); + expect(results.length, 1); + expect(results.first.name, 'SQL Tools'); + }); + + test('install throws MarketplaceException on SHA256 checksum mismatch', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('test.txt', 4, utf8.encode('good'))); + final zipBytes = ZipEncoder().encode(archive); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + const manifest = ExtensionManifest( + id: 'test.sha256', + name: 'SHA256 Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + sha256Checksum: '0000000000000000000000000000000000000000000000000000000000000000', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SHA256 checksum mismatch'), + )), + ); + }); + + test('install prevents Path Traversal during archive unpacking (Issue #242)', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); + final zipBytes = ZipEncoder().encode(archive); + final expectedSha256 = sha256.convert(zipBytes).toString(); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final manifest = ExtensionManifest( + id: 'test.traversal', + name: 'Traversal Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/evil.zip', + sha256Checksum: expectedSha256, + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Security violation'), + )), + ); + }); + }); +} + + + diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index bee13adf..fdb76f02 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -116,5 +116,44 @@ void main() { expect(s.password, isNull); expect(s.connectionString, isNull); }); + + test('updateConnection atomically updates SQLite row and secure-store secrets', () async { + const initialRow = ConnectionRow( + type: 'postgres', + name: 'PG_Init', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'old-secret-password', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final updatedRow = ConnectionRow( + id: id, + type: 'postgres', + name: 'PG_Updated', + host: 'db.example.com', + port: 5433, + username: 'root', + password: 'new-secret-password', + connectionString: 'postgres://root:new-secret-password@db.example.com:5433/mydb', + createdAt: '2026-01-01T00:00:00Z', + ); + await LocalDb.instance.updateConnection(updatedRow); + + final list = await LocalDb.instance.getConnections(); + final loaded = list.singleWhere((c) => c.id == id); + expect(loaded.name, 'PG_Updated'); + expect(loaded.host, 'db.example.com'); + expect(loaded.port, 5433); + expect(loaded.username, 'root'); + expect(loaded.password, 'new-secret-password'); + expect(loaded.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + + final secrets = await ConnectionSecretsStore.readForConnection(id); + expect(secrets.password, 'new-secret-password'); + expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + }); }); } diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart new file mode 100644 index 00000000..48ce2bf2 --- /dev/null +++ b/test/features/extensions/extension_manager_test.dart @@ -0,0 +1,134 @@ +import 'dart:io'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('ExtensionCard', () { + testWidgets('renders manifest details and tag badges', (tester) async { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + description: 'Full support for ClickHouse databases.', + tags: ['database', 'clickhouse', 'olap'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ExtensionCard( + manifest: manifest, + isInstalled: false, + ), + ), + ), + ); + + expect(find.text('ClickHouse Driver'), findsOneWidget); + expect(find.text('QueryaHub'), findsOneWidget); + expect(find.text('v1.0.0'), findsOneWidget); + expect(find.text('Full support for ClickHouse databases.'), findsOneWidget); + expect(find.text('clickhouse'), findsOneWidget); + expect(find.text('Install'), findsOneWidget); + }); + + testWidgets('renders progress bar when isInstalling is true', (tester) async { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ExtensionCard( + manifest: manifest, + isInstalled: false, + isInstalling: true, + installProgress: 0.45, + ), + ), + ), + ); + + expect(find.byType(material.LinearProgressIndicator), findsOneWidget); + expect(find.text('Installing 45%'), findsOneWidget); + }); + }); + + group('ExtensionManagerDialog', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_ui_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + MarketplaceRepository.instance = MockMarketplaceRepository(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + testWidgets('renders tabs and loads Marketplace items', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (ctx) => material.Scaffold( + body: material.Center( + child: PrimaryButton( + onPressed: () => showExtensionManagerDialog(ctx), + child: const Text('Open Dialog'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Extensions'), findsOneWidget); + expect(find.text('Installed (0)'), findsOneWidget); + expect(find.text('Marketplace'), findsOneWidget); + + // Switch to Marketplace tab + await tester.tap(find.text('Marketplace')); + await tester.pumpAndSettle(); + + expect(find.text('ClickHouse Driver'), findsOneWidget); + expect(find.text('Redis Driver'), findsOneWidget); + + // Search filtering + final finder = find.byType(TextField); + expect(finder, findsOneWidget); + + await tester.enterText(finder, 'Nord'); + await tester.pumpAndSettle(); + + expect(find.text('Nord Theme'), findsOneWidget); + expect(find.text('ClickHouse Driver'), findsNothing); + }); + }); +}