import 'dart:convert'; 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/editor/sources.dart'; import 'package:sm_mobile/services/editor/text_codec.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'; /// Editing remote files over a real, loopback-only OpenSSH server /// (tool/test_openssh.py). The remote directory is local, so the tests can /// change files "behind the editor's back" and inspect inodes. void main() { TestWidgetsFlutterBinding.ensureInitialized(); final enabled = Platform.environment['SM_OPENSSH_TEST'] == '1'; late Vault vault; late SshService ssh; late SftpService sftp; late String root; setUpAll(() async { if (!enabled) return; vault = Vault(MemoryStore()); await vault.create('sftp editor test password'); ssh = SshService(vault); final profile = ServerProfile( name: 'Isolated OpenSSH', host: '127.0.0.1', username: Platform.environment['USER']!, port: 22229, auth: AuthMethod.privateKey, ); await ssh.connect( profile, credential: await File( Platform.environment['SM_TEST_KEY']!, ).readAsString(), passphrase: 'isolated-test-passphrase', prompt: (_, _, _) async => true, ); sftp = SftpService(await ssh.client.sftp()); root = await Directory( Platform.environment['SM_TEST_REMOTE_DIR']!, ).resolveSymbolicLinks(); root = '$root/editor-${newId()}'; await Directory(root).create(); }); tearDownAll(() { if (enabled) { ssh.dispose(); vault.lock(); } }); RemoteSource source(String path) => RemoteSource( service: () async => sftp, profileId: 'p', profileName: 'test', path: path, ); List leftovers() => Directory(root) .listSync() .map((e) => e.path.split('/').last) .where((n) => n.contains('.sm-')) .toList(); test( 'open, edit and save keeps permissions; no temporary files remain', () async { final file = File('$root/nginx.conf') ..writeAsStringSync('server {\r\n listen 80;\r\n}\r\n'); await Process.run('chmod', ['640', file.path]); final s = source(file.path); final decoded = decodeDocument(await s.load()); expect(decoded.lineBreaks.dominant, LineBreak.crlf); final edited = encodeText( withLineBreaks('${decoded.text}# edited\n', LineBreak.crlf), decoded.encoding, ); await s.save(edited); expect(file.readAsBytesSync(), edited); final mode = (await Process.run('stat', ['-f', '%Lp', file.path])).stdout; expect(mode.toString().trim(), '640'); expect(leftovers(), isEmpty); }, skip: !enabled, ); test( 'a change on the server with equal size and time is a conflict', () async { final file = File('$root/app.env')..writeAsStringSync('A=1\nB=2\n'); final s = source(file.path); await s.load(); final stamp = file.lastModifiedSync(); file.writeAsStringSync('A=9\nB=2\n'); // same size file.setLastModifiedSync(stamp); // same time await expectLater( s.save(Uint8List.fromList(utf8.encode('A=1\nB=3\n'))), throwsA(isA().having((f) => f.code, 'code', 'remoteChanged')), ); expect(file.readAsStringSync(), 'A=9\nB=2\n', reason: 'untouched'); await s.save(Uint8List.fromList(utf8.encode('A=1\nB=3\n')), force: true); expect(file.readAsStringSync(), 'A=1\nB=3\n'); file.deleteSync(); await expectLater( s.save(Uint8List.fromList(utf8.encode('x'))), throwsA(isA().having((f) => f.code, 'code', 'remoteDeleted')), ); expect(leftovers(), isEmpty); }, skip: !enabled, ); test( 'read-only folder: no atomic save, explicit in-place write keeps inode', () async { final dir = Directory('$root/locked')..createSync(); final file = File('${dir.path}/site.yaml')..writeAsStringSync('a: 1\n'); final inode = (await Process.run('stat', ['-f', '%i', file.path])).stdout; await Process.run('chmod', ['555', dir.path]); try { final s = source(file.path); await s.load(); await expectLater( s.save(Uint8List.fromList(utf8.encode('a: 2\n'))), throwsA( isA().having((f) => f.code, 'code', 'atomicNotPossible'), ), ); expect(file.readAsStringSync(), 'a: 1\n'); await s.save(Uint8List.fromList(utf8.encode('a: 2\n')), inPlace: true); expect(file.readAsStringSync(), 'a: 2\n'); expect( (await Process.run('stat', ['-f', '%i', file.path])).stdout, inode, ); } finally { await Process.run('chmod', ['755', dir.path]); } }, skip: !enabled, ); test('no write permission is reported, the file stays intact', () async { final file = File('$root/readonly.txt')..writeAsStringSync('keep\n'); await Process.run('chmod', ['444', file.path]); final s = source(file.path); await s.load(); // The folder is writable, so a rename could replace the file; the // missing write permission on the file itself must stop the save. for (final inPlace in [false, true]) { await expectLater( s.save(Uint8List.fromList(utf8.encode('x\n')), inPlace: inPlace), throwsA( isA().having((f) => f.code, 'code', 'permissionDenied'), ), ); } expect(file.readAsStringSync(), 'keep\n'); }, skip: !enabled); test('symbolic links are not followed for editing', () async { File('$root/target.txt').writeAsStringSync('t'); Link('$root/link.txt').createSync('$root/target.txt'); await expectLater( source('$root/link.txt').load(), throwsA(isA().having((f) => f.code, 'code', 'unsafePath')), ); }, skip: !enabled); test( 'sizes from 1 KB to 10 MB over SFTP', () async { for (final kb in [1, 100, 1024, 5 * 1024, 10 * 1024]) { final bytes = Uint8List.fromList( List.generate(kb * 1024, (i) => i % 64 == 63 ? 10 : 97 + i % 26), ); final file = File('$root/size-$kb.txt')..writeAsBytesSync(bytes); final s = source(file.path); final watch = Stopwatch()..start(); final loaded = await s.load(); final read = watch.elapsedMilliseconds; expect(loaded, bytes); final changed = Uint8List.fromList([65, ...bytes]); watch.reset(); await s.save(changed); // ignore: avoid_print print( 'sftp $kb KB: read $read ms, safe save ${watch.elapsedMilliseconds} ms', ); expect(file.readAsBytesSync(), changed); } expect(leftovers(), isEmpty); }, skip: !enabled, timeout: const Timeout(Duration(minutes: 3)), ); }