import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:sm_mobile/core/failure.dart'; import 'package:sm_mobile/models/server_profile.dart'; import 'package:sm_mobile/services/monitor.dart'; import 'package:sm_mobile/services/sftp_service.dart'; import 'package:sm_mobile/services/ssh_service.dart'; import 'package:sm_mobile/services/vault.dart'; import '../support.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final enabled = Platform.environment['SM_OPENSSH_TEST'] == '1'; late Vault vault; late SshService ssh; late ServerProfile profile; late String key, root; setUpAll(() async { if (!enabled) return; vault = Vault(MemoryStore()); await vault.create('isolated integration master password'); ssh = SshService(vault); profile = ServerProfile( name: 'Isolated OpenSSH', host: '127.0.0.1', username: Platform.environment['USER']!, port: 22229, auth: AuthMethod.privateKey, ); key = await File(Platform.environment['SM_TEST_KEY']!).readAsString(); root = Platform.environment['SM_TEST_REMOTE_DIR']!; await Directory(root).create(recursive: true); root = await Directory(root).resolveSymbolicLinks(); }); tearDownAll(() { if (enabled) { ssh.dispose(); vault.lock(); } }); test( 'real OpenSSH: encrypted key, PTY, resize, streaming SFTP, symlink rejection and disconnect', () async { var prompts = 0; await ssh.connect( profile, credential: key, passphrase: 'isolated-test-passphrase', prompt: (endpoint, type, fingerprint) async { prompts++; expect(fingerprint, startsWith('SHA256:')); return true; }, ); expect(ssh.error, null); expect(ssh.connected, true); expect(prompts, 1); final nonce = newId(); final marker = 'SM_PTY_$nonce'; ssh.terminal.resize(100, 35); ssh.terminal.onOutput!( 'printf "\\033[32mSM_PTY_%s\\033[0m\\n" "$nonce"\r', ); final deadline = DateTime.now().add(const Duration(seconds: 10)); while (!ssh.terminal.buffer.getText().contains(marker) && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 50)); } expect(ssh.terminal.buffer.getText(), contains(marker)); final sftp = SftpService(await ssh.client.sftp()); final dir = '$root/run-${newId()}'; await sftp.mkdir(dir); final payload = Uint8List.fromList( List.generate(256 * 1024, (i) => i % 251), ); final temp = await Directory.systemTemp.createTemp('sm-download-test-'); try { var progress = 0; await sftp.upload( Stream.value(payload), payload.length, '$dir/input.bin', TransferToken(), (n, size) { progress = n; }, ); expect(progress, payload.length); expect( (await sftp.list(dir)).map((e) => e.filename), contains('input.bin'), ); await expectLater( sftp.upload( Stream.value(payload), payload.length, '$dir/input.bin', TransferToken(), (a, b) {}, ), throwsA(isA().having((e) => e.code, 'code', 'fileExists')), ); final download = File('${temp.path}/output.bin'); await sftp.download( '$dir/input.bin', download, TransferToken(), (a, b) {}, ); expect(await download.readAsBytes(), payload); await sftp.rename('$dir/input.bin', '$dir/renamed.bin'); await Link('$dir/link.bin').create('$dir/renamed.bin'); await expectLater( sftp.download( '$dir/link.bin', File('${temp.path}/bad.bin'), TransferToken(), (a, b) {}, ), throwsA(isA()), ); await sftp.client.remove('$dir/link.bin'); final token = TransferToken(); Stream slow() async* { for (var i = 0; i < 20; i++) { yield payload; await Future.delayed(const Duration(milliseconds: 10)); } } final upload = sftp.upload( slow(), payload.length * 20, '$dir/cancelled.bin', token, (n, size) { if (n > 0) token.cancel(); }, ); await expectLater(upload, throwsA(anything)); expect( (await sftp.list(dir)).map((e) => e.filename), isNot(contains('cancelled.bin')), ); expect( (await sftp.list(dir)).where((e) => e.filename.endsWith('.part')), isEmpty, ); final cancelledFile = File('${temp.path}/cancelled-download.bin'); final downloadToken = TransferToken(); await expectLater( sftp.download('$dir/renamed.bin', cancelledFile, downloadToken, ( n, size, ) { if (n > 0) downloadToken.cancel(); }), throwsA(anything), ); expect(await cancelledFile.exists(), false); await expectLater( sftp.upload( Stream.error(const Failure('fileError')), 0, '$dir/source-error.bin', TransferToken(), (a, b) {}, ), throwsA(isA()), ); expect( (await sftp.list(dir)).where((e) => e.filename.endsWith('.part')), isEmpty, ); await sftp.remove('$dir/renamed.bin'); await sftp.remove(dir); // macOS fixture deliberately verifies unsupported Linux adapter behavior. if (Platform.isLinux) { final metrics = await LinuxMonitor().sample(ssh.client); expect( metrics.keys, containsAll([ 'cpu', 'ram', 'disk', 'uptime', 'load', 'system', 'release', 'rtt', ]), ); } else { await expectLater( LinuxMonitor().sample(ssh.client), throwsA( isA().having((e) => e.code, 'code', 'unsupportedSystem'), ), ); } } finally { await temp.delete(recursive: true); sftp.client.close(); } ssh.disconnect(); expect(ssh.connected, false); await ssh.connect( profile, credential: key, passphrase: 'isolated-test-passphrase', prompt: (a, b, c) async { fail('known key must not prompt'); }, ); expect(ssh.connected, true); ssh.terminal.onOutput!('exit\r'); final closed = DateTime.now().add(const Duration(seconds: 5)); while (ssh.connected && DateTime.now().isBefore(closed)) { await Future.delayed(const Duration(milliseconds: 30)); } expect(ssh.connected, false); }, skip: !enabled, timeout: const Timeout(Duration(minutes: 2)), ); test( 'SSH timeout and lock during a pending connection close actual sockets', () async { final server = await ServerSocket.bind('127.0.0.1', 0); final sockets = []; final subscription = server.listen(sockets.add); final service = SshService( vault, handshakeTimeout: const Duration(milliseconds: 200), ); final target = ServerProfile( name: 'Silent peer', host: '127.0.0.1', username: 'test', port: server.port, ); try { await service.connect( target, credential: 'not logged', prompt: (a, b, c) async => false, ); expect(service.connected, false); expect(service.connecting, false); expect(service.error, 'timeout'); final pending = service.connect( target, credential: 'not logged', prompt: (a, b, c) async => false, ); await Future.delayed(const Duration(milliseconds: 50)); service.disconnect(clear: true); await pending; expect(service.connected, false); expect(service.profile, null); } finally { service.dispose(); for (final s in sockets) { s.destroy(); } await subscription.cancel(); await server.close(); } }, skip: !enabled, ); test( 'real host-key change blocks before authentication; bad credentials fail', () async { final original = vault.fingerprint(profile.endpoint)!; await vault.trust( profile.endpoint, 'SHA256:${List.filled(43, 'z').join()}', ); await ssh.connect( profile, credential: key, passphrase: 'isolated-test-passphrase', prompt: (a, b, c) async { fail('Changed keys must never be offered as first use.'); }, ); expect(ssh.connected, false); expect(ssh.error, 'hostChanged'); await vault.trust(profile.endpoint, original); final invalid = ServerProfile( name: 'Bad login', host: profile.host, port: profile.port, username: 'nonexistent_sm_${newId()}', auth: AuthMethod.password, ); await ssh.connect( invalid, credential: 'dummy-test-password', prompt: (a, b, c) async => false, ); expect(ssh.connected, false); expect(ssh.error, 'authFailed'); }, skip: !enabled, ); test('transport loss closes the live SSH session', () async { final proxy = await ServerSocket.bind('127.0.0.1', 0); final peers = []; final sub = proxy.listen((incoming) async { peers.add(incoming); final outgoing = await Socket.connect('127.0.0.1', 22229); peers.add(outgoing); incoming.listen( outgoing.add, onError: (Object _) {}, onDone: outgoing.destroy, ); outgoing.listen( incoming.add, onError: (Object _) {}, onDone: incoming.destroy, ); }); try { final target = ServerProfile( name: 'Loss test', host: '127.0.0.1', port: proxy.port, username: profile.username, auth: AuthMethod.privateKey, ); await ssh.connect( target, credential: key, passphrase: 'isolated-test-passphrase', prompt: (a, b, c) async => true, ); expect(ssh.connected, true); for (final peer in peers) { peer.destroy(); } for (var i = 0; i < 100 && ssh.connected; i++) { await Future.delayed(const Duration(milliseconds: 20)); } expect(ssh.connected, false); expect(() => ssh.client, throwsA(isA())); } finally { for (final peer in peers) { peer.destroy(); } await sub.cancel(); await proxy.close(); } }, skip: !enabled); }