import 'dart:convert'; import 'dart:io'; 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/host_verifier.dart'; import 'package:sm_mobile/services/diagnostics.dart'; import 'package:sm_mobile/services/profile_transfer.dart'; import 'package:sm_mobile/services/sftp_service.dart'; import 'package:sm_mobile/services/vault.dart'; import 'support.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); const master = 'a sufficiently long master password'; late MemoryStore store; late Vault vault; setUpAll(() async { store = MemoryStore(); vault = Vault(store); await vault.create(master); }); tearDownAll(() => vault.lock()); test('profiles preserve original DNS without persisting resolved IP', () { final p = ServerProfile( name: 'DNS', host: 'Example.COM', username: 'Admin', ); expect(p.host, 'Example.COM'); expect(p.portable().keys, ['name', 'host', 'username', 'port']); final a = ServerProfile( name: 'v6', host: '[2001:db8::1]', username: 'root', ); final b = ServerProfile( name: 'v6', host: '2001:0db8:0:0:0:0:0:1', username: 'root', ); expect(a.identity, b.identity); }); test('invalid hosts, ports and control characters fail closed', () { for (final host in [ 'host:22', 'ssh://host', 'bad host', '[invalid]', 'host/path', 'host\n', ]) { // Leading/trailing whitespace is intentionally trimmed. if (host == 'host\n') continue; expect( () => ServerProfile(name: 'n', host: host, username: 'u'), throwsA(isA()), ); } expect( () => ServerProfile(name: 'n', host: '::1', username: 'u', port: 65536), throwsA(isA()), ); expect( () => ServerProfile(name: 'n', host: '::1', username: 'a\u0000b'), throwsA(isA()), ); }); test('portable JSON matches desktop v1 and drops arbitrary secrets', () { final bytes = utf8.encode( jsonEncode({ 'format': 'chj-server-profiles', 'version': 1, 'servers': [ { 'name': 'N', 'host': 'example.com', 'username': 'admin', 'password': 'DO_NOT_EXPORT', 'privateKey': 'PRIVATE', 'fingerprint': 'UNTRUSTED', }, ], }), ); final decoded = ProfileTransfer.decode(bytes); final text = utf8.decode(ProfileTransfer.encode(decoded)); expect(text, isNot(contains('DO_NOT_EXPORT'))); expect(text, isNot(contains('PRIVATE'))); expect(text, isNot(contains('fingerprint'))); expect(jsonDecode(text)['servers'][0], { 'name': 'N', 'host': 'example.com', 'username': 'admin', 'port': 22, }); expect(decoded.single.auth, AuthMethod.password); }); test( 'invalid import rejects whole document, unknown versions and oversized input', () { for (final text in [ '{}', '{"format":"chj-server-profiles","version":2,"servers":[]}', '{"format":"chj-server-profiles","version":1,"servers":[{"name":"x","host":"x","username":"u"},{}]}', ]) { expect( () => ProfileTransfer.decode(utf8.encode(text)), throwsA(isA()), ); } expect( () => ProfileTransfer.decode( List.filled(ProfileTransfer.maxBytes + 1, 32), ), throwsA(isA()), ); }, ); test( 'Vault encrypts profiles, passwords and keys; failed writes do not mutate live data', () async { final p = ServerProfile( name: 'secret server label', host: 'private.example', username: 'u', ); await vault.save( p, password: 'never-cleartext', privateKey: 'PRIVATE_TEST_KEY', ); final text = store.values['vault']!; for (final secret in [ 'never-cleartext', 'PRIVATE_TEST_KEY', 'private.example', 'secret server label', master, ]) { expect(text, isNot(contains(secret))); } store.failWrites = true; await expectLater(vault.deleteProfile(p.id), throwsStateError); expect(vault.secret(p.id, 'password'), 'never-cleartext'); store.failWrites = false; }, ); test( 'first host requires consent; known host never prompts; changed key blocks', () async { final verifier = HostVerifier(vault); final fp = 'SHA256:${List.filled(43, 'a').join()}'; final changed = 'SHA256:${List.filled(43, 'b').join()}'; var prompts = 0; Future prompt(String a, String b, String c) async { prompts++; return true; } expect(await verifier.verify('test:22', 'ssh-ed25519', fp, prompt), true); expect(await verifier.verify('test:22', 'ssh-ed25519', fp, prompt), true); expect(prompts, 1); await expectLater( verifier.verify('test:22', 'ssh-ed25519', changed, prompt), throwsA(isA().having((e) => e.code, 'code', 'hostChanged')), ); expect(prompts, 1); await expectLater( verifier.verify( 'rejected:22', 'ssh-ed25519', fp, (a, b, c) async => false, ), throwsA(isA()), ); expect(vault.fingerprint('rejected:22'), null); }, ); test( 'locking blocks access and invalidates pending trust decision', () async { final copy = Vault(MemoryStore(store.values)); await copy.unlock(master); final fp = 'SHA256:${List.filled(43, 'c').join()}'; await expectLater( HostVerifier(copy).verify('pending:22', 'ssh-ed25519', fp, ( a, b, c, ) async { copy.lock(); return true; }), throwsA(isA()), ); expect(() => copy.profiles, throwsA(isA())); expect(() => copy.secret('x', 'password'), throwsA(isA())); }, ); test( 'wrong password and altered authentication tag fail; original can still unlock', () async { final local = MemoryStore(store.values); final copy = Vault(local); await expectLater( copy.unlock('incorrect-password'), throwsA(isA()), ); expect(copy.unlocked, false); final envelope = jsonDecode(local.values['vault']!); final data = base64Decode(envelope['data']); data[data.length - 1] ^= 1; envelope['data'] = base64Encode(data); local.values['vault'] = jsonEncode(envelope); await expectLater(copy.unlock(master), throwsA(isA())); expect(copy.unlocked, false); local.values['vault'] = store.values['vault']!; await copy.unlock(master); expect(copy.unlocked, true); copy.lock(); }, ); test( 'biometric key unavailable does not bypass encryption; fallback works', () async { final gated = MemoryStore(); await vault.enableBiometric(gated); final copy = Vault(MemoryStore(store.values)); await expectLater( copy.unlockBiometric(MemoryStore()), throwsA(isA()), ); expect(copy.unlocked, false); await copy.unlockBiometric(gated); expect(copy.unlocked, true); copy.lock(); }, ); test('unsafe path names and traversal are rejected', () { for (final name in ['.', '..', '../x', 'x/y', 'x\\y', 'x\u0000']) { expect(() => safeName(name), throwsA(isA())); } for (final path in [ '/../etc', '/home/../etc', 'relative', '/a/./b', '/a\\b', ]) { expect(() => safePath(path), throwsA(isA())); } expect(childPath('/', 'file'), '/file'); expect(parentPath('/a'), '/'); }); test('DNS/TCP probe reports TCP timing without changing profiles', () async { final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final accepted = socket.listen((s) => s.destroy()); final result = await Diagnostics().probe( '127.0.0.1', socket.port, type: InternetAddressType.IPv4, ); expect(result.addresses, ['127.0.0.1']); expect(result.tcpMs, greaterThanOrEqualTo(0)); await accepted.cancel(); await socket.close(); }); }