CH-J SM Mobilegestión de servidores a través de SSH
Menú
Fuente publicada

CH-J SM Mobile

Busque directorios y archivos para obtener una versión de aplicación específica.

Descargar fuente ZIP
CH-J / SM Mobile 134 archivos
sm-mobile-20261016T050549Z-3678f2a05fa9 Añadido: · Construido:
Archivos
openssh_test.dart Añadido: Fecha no registrada 10,9 KB · 362 líneas
Descargar archivo
1import 'dart:async';
2import 'dart:io';
3import 'dart:typed_data';
4import 'package:flutter_test/flutter_test.dart';
5import 'package:sm_mobile/core/failure.dart';
6import 'package:sm_mobile/models/server_profile.dart';
7import 'package:sm_mobile/services/monitor.dart';
8import 'package:sm_mobile/services/sftp_service.dart';
9import 'package:sm_mobile/services/ssh_service.dart';
10import 'package:sm_mobile/services/vault.dart';
11import '../support.dart';
13void main() {
14 TestWidgetsFlutterBinding.ensureInitialized();
15 final enabled = Platform.environment['SM_OPENSSH_TEST'] == '1';
16 late Vault vault;
17 late SshService ssh;
18 late ServerProfile profile;
19 late String key, root;
20 setUpAll(() async {
21 if (!enabled) return;
22 vault = Vault(MemoryStore());
23 await vault.create('isolated integration master password');
24 ssh = SshService(vault);
25 profile = ServerProfile(
26 name: 'Isolated OpenSSH',
27 host: '127.0.0.1',
28 username: Platform.environment['USER']!,
29 port: 22229,
30 auth: AuthMethod.privateKey,
31 );
32 key = await File(Platform.environment['SM_TEST_KEY']!).readAsString();
33 root = Platform.environment['SM_TEST_REMOTE_DIR']!;
34 await Directory(root).create(recursive: true);
35 root = await Directory(root).resolveSymbolicLinks();
36 });
37 tearDownAll(() {
38 if (enabled) {
39 ssh.dispose();
40 vault.lock();
41 }
42 });
43 test(
44 'real OpenSSH: encrypted key, PTY, resize, streaming SFTP, symlink rejection and disconnect',
45 () async {
46 var prompts = 0;
47 await ssh.connect(
48 profile,
49 credential: key,
50 passphrase: 'isolated-test-passphrase',
51 prompt: (endpoint, type, fingerprint) async {
52 prompts++;
53 expect(fingerprint, startsWith('SHA256:'));
54 return true;
55 },
56 );
57 expect(ssh.error, null);
58 expect(ssh.connected, true);
59 expect(prompts, 1);
60 final nonce = newId();
61 final marker = 'SM_PTY_$nonce';
62 ssh.terminal.resize(100, 35);
63 ssh.terminal.onOutput!(
64 'printf "\\033[32mSM_PTY_%s\\033[0m\\n" "$nonce"\r',
65 );
66 final deadline = DateTime.now().add(const Duration(seconds: 10));
67 while (!ssh.terminal.buffer.getText().contains(marker) &&
68 DateTime.now().isBefore(deadline)) {
69 await Future<void>.delayed(const Duration(milliseconds: 50));
70 }
71 expect(ssh.terminal.buffer.getText(), contains(marker));
72 final sftp = SftpService(await ssh.client.sftp());
73 final dir = '$root/run-${newId()}';
74 await sftp.mkdir(dir);
75 final payload = Uint8List.fromList(
76 List.generate(256 * 1024, (i) => i % 251),
77 );
78 final temp = await Directory.systemTemp.createTemp('sm-download-test-');
79 try {
80 var progress = 0;
81 await sftp.upload(
82 Stream.value(payload),
83 payload.length,
84 '$dir/input.bin',
85 TransferToken(),
86 (n, size) {
87 progress = n;
88 },
89 );
90 expect(progress, payload.length);
91 expect(
92 (await sftp.list(dir)).map((e) => e.filename),
93 contains('input.bin'),
94 );
95 await expectLater(
96 sftp.upload(
97 Stream.value(payload),
98 payload.length,
99 '$dir/input.bin',
100 TransferToken(),
101 (a, b) {},
102 ),
103 throwsA(isA<Failure>().having((e) => e.code, 'code', 'fileExists')),
104 );
105 final download = File('${temp.path}/output.bin');
106 await sftp.download(
107 '$dir/input.bin',
108 download,
109 TransferToken(),
110 (a, b) {},
111 );
112 expect(await download.readAsBytes(), payload);
113 await sftp.rename('$dir/input.bin', '$dir/renamed.bin');
114 await Link('$dir/link.bin').create('$dir/renamed.bin');
115 await expectLater(
116 sftp.download(
117 '$dir/link.bin',
118 File('${temp.path}/bad.bin'),
119 TransferToken(),
120 (a, b) {},
121 ),
122 throwsA(isA<Failure>()),
123 );
124 await sftp.client.remove('$dir/link.bin');
125 final token = TransferToken();
126 Stream<Uint8List> slow() async* {
127 for (var i = 0; i < 20; i++) {
128 yield payload;
129 await Future<void>.delayed(const Duration(milliseconds: 10));
130 }
131 }
133 final upload = sftp.upload(
134 slow(),
135 payload.length * 20,
136 '$dir/cancelled.bin',
137 token,
138 (n, size) {
139 if (n > 0) token.cancel();
140 },
141 );
142 await expectLater(upload, throwsA(anything));
143 expect(
144 (await sftp.list(dir)).map((e) => e.filename),
145 isNot(contains('cancelled.bin')),
146 );
147 expect(
148 (await sftp.list(dir)).where((e) => e.filename.endsWith('.part')),
149 isEmpty,
150 );
151 final cancelledFile = File('${temp.path}/cancelled-download.bin');
152 final downloadToken = TransferToken();
153 await expectLater(
154 sftp.download('$dir/renamed.bin', cancelledFile, downloadToken, (
155 n,
156 size,
157 ) {
158 if (n > 0) downloadToken.cancel();
159 }),
160 throwsA(anything),
161 );
162 expect(await cancelledFile.exists(), false);
163 await expectLater(
164 sftp.upload(
165 Stream<Uint8List>.error(const Failure('fileError')),
166 0,
167 '$dir/source-error.bin',
168 TransferToken(),
169 (a, b) {},
170 ),
171 throwsA(isA<Failure>()),
172 );
173 expect(
174 (await sftp.list(dir)).where((e) => e.filename.endsWith('.part')),
175 isEmpty,
176 );
177 await sftp.remove('$dir/renamed.bin');
178 await sftp.remove(dir);
179 // macOS fixture deliberately verifies unsupported Linux adapter behavior.
180 if (Platform.isLinux) {
181 final metrics = await LinuxMonitor().sample(ssh.client);
182 expect(
183 metrics.keys,
184 containsAll([
185 'cpu',
186 'ram',
187 'disk',
188 'uptime',
189 'load',
190 'system',
191 'release',
192 'rtt',
193 ]),
194 );
195 } else {
196 await expectLater(
197 LinuxMonitor().sample(ssh.client),
198 throwsA(
199 isA<Failure>().having((e) => e.code, 'code', 'unsupportedSystem'),
200 ),
201 );
202 }
203 } finally {
204 await temp.delete(recursive: true);
205 sftp.client.close();
206 }
207 ssh.disconnect();
208 expect(ssh.connected, false);
209 await ssh.connect(
210 profile,
211 credential: key,
212 passphrase: 'isolated-test-passphrase',
213 prompt: (a, b, c) async {
214 fail('known key must not prompt');
215 },
216 );
217 expect(ssh.connected, true);
218 ssh.terminal.onOutput!('exit\r');
219 final closed = DateTime.now().add(const Duration(seconds: 5));
220 while (ssh.connected && DateTime.now().isBefore(closed)) {
221 await Future<void>.delayed(const Duration(milliseconds: 30));
222 }
223 expect(ssh.connected, false);
224 },
225 skip: !enabled,
226 timeout: const Timeout(Duration(minutes: 2)),
227 );
229 test(
230 'SSH timeout and lock during a pending connection close actual sockets',
231 () async {
232 final server = await ServerSocket.bind('127.0.0.1', 0);
233 final sockets = <Socket>[];
234 final subscription = server.listen(sockets.add);
235 final service = SshService(
236 vault,
237 handshakeTimeout: const Duration(milliseconds: 200),
238 );
239 final target = ServerProfile(
240 name: 'Silent peer',
241 host: '127.0.0.1',
242 username: 'test',
243 port: server.port,
244 );
245 try {
246 await service.connect(
247 target,
248 credential: 'not logged',
249 prompt: (a, b, c) async => false,
250 );
251 expect(service.connected, false);
252 expect(service.connecting, false);
253 expect(service.error, 'timeout');
254 final pending = service.connect(
255 target,
256 credential: 'not logged',
257 prompt: (a, b, c) async => false,
258 );
259 await Future<void>.delayed(const Duration(milliseconds: 50));
260 service.disconnect(clear: true);
261 await pending;
262 expect(service.connected, false);
263 expect(service.profile, null);
264 } finally {
265 service.dispose();
266 for (final s in sockets) {
267 s.destroy();
268 }
269 await subscription.cancel();
270 await server.close();
271 }
272 },
273 skip: !enabled,
274 );
276 test(
277 'real host-key change blocks before authentication; bad credentials fail',
278 () async {
279 final original = vault.fingerprint(profile.endpoint)!;
280 await vault.trust(
281 profile.endpoint,
282 'SHA256:${List.filled(43, 'z').join()}',
283 );
284 await ssh.connect(
285 profile,
286 credential: key,
287 passphrase: 'isolated-test-passphrase',
288 prompt: (a, b, c) async {
289 fail('Changed keys must never be offered as first use.');
290 },
291 );
292 expect(ssh.connected, false);
293 expect(ssh.error, 'hostChanged');
294 await vault.trust(profile.endpoint, original);
295 final invalid = ServerProfile(
296 name: 'Bad login',
297 host: profile.host,
298 port: profile.port,
299 username: 'nonexistent_sm_${newId()}',
300 auth: AuthMethod.password,
301 );
302 await ssh.connect(
303 invalid,
304 credential: 'dummy-test-password',
305 prompt: (a, b, c) async => false,
306 );
307 expect(ssh.connected, false);
308 expect(ssh.error, 'authFailed');
309 },
310 skip: !enabled,
311 );
313 test('transport loss closes the live SSH session', () async {
314 final proxy = await ServerSocket.bind('127.0.0.1', 0);
315 final peers = <Socket>[];
316 final sub = proxy.listen((incoming) async {
317 peers.add(incoming);
318 final outgoing = await Socket.connect('127.0.0.1', 22229);
319 peers.add(outgoing);
320 incoming.listen(
321 outgoing.add,
322 onError: (Object _) {},
323 onDone: outgoing.destroy,
324 );
325 outgoing.listen(
326 incoming.add,
327 onError: (Object _) {},
328 onDone: incoming.destroy,
329 );
330 });
331 try {
332 final target = ServerProfile(
333 name: 'Loss test',
334 host: '127.0.0.1',
335 port: proxy.port,
336 username: profile.username,
337 auth: AuthMethod.privateKey,
338 );
339 await ssh.connect(
340 target,
341 credential: key,
342 passphrase: 'isolated-test-passphrase',
343 prompt: (a, b, c) async => true,
344 );
345 expect(ssh.connected, true);
346 for (final peer in peers) {
347 peer.destroy();
348 }
349 for (var i = 0; i < 100 && ssh.connected; i++) {
350 await Future<void>.delayed(const Duration(milliseconds: 20));
351 }
352 expect(ssh.connected, false);
353 expect(() => ssh.client, throwsA(isA<Failure>()));
354 } finally {
355 for (final peer in peers) {
356 peer.destroy();
357 }
358 await sub.cancel();
359 await proxy.close();
360 }
361 }, skip: !enabled);
SHA-256832d7f22207f680e45ccd7da0d7f16b675a084fdbfe1f776c13f812f0e01c4ce

CH-J Proprietary Software License 1.14 · La fuente se proporciona en el CH-J Proprietary Software License 1.14. Su disponibilidad no cambia los términos de la licencia ni otorga derechos adicionales.

Archivo SHA-256c486a807e4828998b9c57ab0f2e760f7980b31965fe118e89d3e1a5a2b65be65