diff --git a/test/e2e/05_extensions_and_marketplace/e2e_extension_rpc_lifecycle_test.dart b/test/e2e/05_extensions_and_marketplace/e2e_extension_rpc_lifecycle_test.dart new file mode 100644 index 00000000..69b410fa --- /dev/null +++ b/test/e2e/05_extensions_and_marketplace/e2e_extension_rpc_lifecycle_test.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/harness/extension_harness.dart'; +import 'package:querya_desktop/core/extensions/harness/extension_harness_report.dart'; + +import 'e2e_stub_driver.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +void main() { + final skip = stubDriverSkipReason(); + late Directory root; + + setUp(() => root = Directory.systemTemp.createTempSync('querya_e2e_rpc_')); + tearDown(() => root.deleteSync(recursive: true)); + + test('a real driver process passes the whole RPC lifecycle in order', + timeout: _timeout, skip: skip, () async { + writeStubDriver(root, mode: 'healthy'); + + final report = await QueryaExtensionHarness( + shutdownTimeout: const Duration(seconds: 3), + ).run(extensionRoot: root.path, targetVersion: '0.5.0'); + + expect(report.isSuccess, isTrue, reason: report.toConsole()); + expect(report.failedCount, 0); + final names = report.checks.map((c) => c.name).toList(); + expect( + names.where((n) => n.startsWith('system.') || n.startsWith('db.')), + containsAllInOrder([ + 'system.handshake', + 'db.connect', + 'db.getSchemaTree', + 'db.query', + 'db.disconnect', + 'system.shutdown', + ]), + ); + expect( + report.checks.firstWhere((c) => c.name == 'db.query').status, + HarnessCheckStatus.passed, + ); + }); + + test('a driver that never answers the handshake fails fast and skips the rest', + timeout: _timeout, skip: skip, () async { + writeStubDriver(root, mode: 'mute'); + + final report = await QueryaExtensionHarness( + requestTimeout: const Duration(seconds: 1), + shutdownTimeout: const Duration(milliseconds: 500), + ).run(extensionRoot: root.path, targetVersion: '0.5.0'); + + expect(report.isSuccess, isFalse); + expect( + report.checks.firstWhere((c) => c.name == 'system.handshake').status, + HarnessCheckStatus.failed, + ); + expect(report.skippedCount, greaterThan(0)); + expect( + report.checks.firstWhere((c) => c.name == 'db.query').status, + HarnessCheckStatus.skipped, + ); + }); + + test('a missing entry point is reported as a launch failure', + timeout: _timeout, () async { + File(p.join(root.path, 'manifest.json')) + .writeAsStringSync(jsonEncode(stubManifest())); + + final report = await QueryaExtensionHarness() + .run(extensionRoot: root.path, targetVersion: '0.5.0'); + + expect(report.isSuccess, isFalse); + expect( + report.checks.firstWhere((c) => c.name == 'launch').message, + contains('entry point not found'), + ); + }); +} diff --git a/test/e2e/05_extensions_and_marketplace/e2e_extension_sandbox_security_test.dart b/test/e2e/05_extensions_and_marketplace/e2e_extension_sandbox_security_test.dart new file mode 100644 index 00000000..b0018ebf --- /dev/null +++ b/test/e2e/05_extensions_and_marketplace/e2e_extension_sandbox_security_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/harness/extension_harness.dart'; + +import 'e2e_stub_driver.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +/// Same probe the harness uses before it wraps a plugin in `bwrap`. +bool _bwrapUsable() { + if (!Platform.isLinux) return false; + try { + return Process.runSync( + 'bwrap', const ['--ro-bind', '/', '/', '/bin/true']).exitCode == + 0; + } catch (_) { + return false; + } +} + +void main() { + final skip = stubDriverSkipReason(); + late Directory root; + late Directory hostDir; + + setUp(() { + root = Directory.systemTemp.createTempSync('querya_e2e_sandbox_ext_'); + hostDir = Directory.systemTemp.createTempSync('querya_e2e_sandbox_host_'); + }); + tearDown(() { + root.deleteSync(recursive: true); + hostDir.deleteSync(recursive: true); + }); + + Future runProbe({required bool sandbox}) async { + final report = await QueryaExtensionHarness( + shutdownTimeout: const Duration(seconds: 3), + ).run( + extensionRoot: root.path, + targetVersion: '0.5.0', + sandbox: sandbox, + ); + expect(report.failedCount, 0, reason: report.toConsole()); + } + + test('without a sandbox the probe writes to the host (control)', + timeout: _timeout, skip: skip, () async { + final target = p.join(hostDir.path, 'escaped.txt'); + writeStubDriver(root, mode: 'probe', probeFile: target); + + await runProbe(sandbox: false); + + expect(File(target).existsSync(), isTrue); + }); + + test('inside bubblewrap the same write cannot reach the host filesystem', + timeout: _timeout, + skip: skip ?? (_bwrapUsable() ? null : 'bwrap is not usable here'), + () async { + final target = p.join(hostDir.path, 'escaped.txt'); + writeStubDriver(root, mode: 'probe', probeFile: target); + + await runProbe(sandbox: true); + + expect(File(target).existsSync(), isFalse); + expect(hostDir.listSync(), isEmpty); + }); +} diff --git a/test/e2e/05_extensions_and_marketplace/e2e_stub_driver.dart b/test/e2e/05_extensions_and_marketplace/e2e_stub_driver.dart new file mode 100644 index 00000000..825f48b5 --- /dev/null +++ b/test/e2e/05_extensions_and_marketplace/e2e_stub_driver.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Why scenarios that start a real driver process cannot run here, or null. +String? stubDriverSkipReason() { + if (Platform.isWindows) return 'the stub driver is a POSIX script'; + try { + if (Process.runSync('python3', const ['--version']).exitCode == 0) { + return null; + } + } catch (_) {} + return 'python3 is not available'; +} + +Map stubManifest() => { + 'id': 'acme.stub', + 'name': 'Stub', + 'version': '1.0.0', + 'type': 'database_driver', + 'main': 'bin/driver', + 'engines': {'querya_desktop': '^0.4.11'}, + 'contributions': { + 'drivers': [ + {'driverId': 'stub', 'displayName': 'Stub'}, + ], + }, + }; + +/// Writes a manifest and an executable JSON-RPC driver speaking newline +/// delimited JSON over stdio. +/// +/// [mode]: `healthy` answers everything, `mute` ignores `system.handshake`, +/// `probe` tries to create [probeFile] during `db.query`. +void writeStubDriver(Directory root, {required String mode, String? probeFile}) { + File(p.join(root.path, 'manifest.json')) + .writeAsStringSync(jsonEncode(stubManifest())); + final entry = File(p.join(root.path, 'bin', 'driver')) + ..createSync(recursive: true) + ..writeAsStringSync('''#!/usr/bin/env python3 +import json +import sys + +MODE = ${jsonEncode(mode)} +PROBE = ${jsonEncode(probeFile ?? '')} + + +def reply(i, result=None): + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": i, "result": result}) + "\\n") + sys.stdout.flush() + + +for line in sys.stdin: + msg = json.loads(line) + method = msg["method"] + i = msg.get("id") + if MODE == "mute" and method == "system.handshake": + continue + if method == "system.shutdown": + reply(i) + sys.exit(0) + if method == "system.handshake": + result = {"protocolVersion": 1} + elif method == "system.ping": + result = "pong" + elif method == "db.connect": + result = {"serverVersion": "e2e"} + elif method == "db.getCapabilities": + result = {"supportsTransactions": False} + elif method == "db.getServerStats": + result = {"uptime": 1} + elif method == "db.getSchemaTree": + result = {"nodes": []} + elif method == "db.query": + if MODE == "probe" and PROBE: + try: + with open(PROBE, "w") as f: + f.write("escaped") + except OSError: + pass + result = {"columns": ["one"], "rows": [[1]]} + elif method == "db.getTableSchema": + result = {"columns": []} + else: + result = None + reply(i, result) +'''); + Process.runSync('chmod', ['+x', entry.path]); +}