import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'fake_protocol_server.dart'; import 'package:mews/protocols/gemini.dart'; import 'package:mews/protocols/tofu.dart'; String fixtureFingerprint(String certName) { final pem = File('test/fixtures/$certName-cert.pem').readAsStringSync(); final body = pem .replaceAll(RegExp(r'-----(BEGIN|END) CERTIFICATE-----'), '') .replaceAll(RegExp(r'\s'), ''); return sha256.convert(base64.decode(body)).toString(); } Future open(String path, FakeGeminiServer server) => Future.value(Uri.parse('gemini://localhost:${server.port}$path')); void main() { late FakeGeminiServer server; late StreamSubscription answers; final client = GeminiClient(timeout: const Duration(seconds: 5)); tearDown(() async { await answers.cancel(); await server.close(); }); test('a success answers with status, meta and body', () async { server = FakeGeminiServer('gemini-test-a'); await server.start(); var requested = ''; answers = server.answer((line, socket) async { requested = line; socket.add(utf8.encode('20 text/gemini\r\nhello')); }); final response = await client.fetch(await open('/', server)); expect(response.status, 20); expect(response.meta, 'text/gemini'); expect(utf8.decode(response.body!), 'hello'); expect(requested, 'gemini://localhost:${server.port}/'); }); test('the first visit pins the presented certificate', () async { server = FakeGeminiServer('gemini-test-a'); await server.start(); answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); final response = await client.fetch(await open('/', server)); expect(response.pinUpdates, hasLength(1)); final update = response.pinUpdates.single; expect(update.host, 'localhost'); expect(update.port, server.port); expect(update.pin.fingerprint, fixtureFingerprint('gemini-test-a')); }); test('a matching pin is left alone', () async { server = FakeGeminiServer('gemini-test-a'); await server.start(); answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); final pins = { 'localhost:${server.port}': TofuPin(fingerprint: fixtureFingerprint('gemini-test-a'), expires: DateTime(2100)), }; final response = await client.fetch(await open('/', server), pins: pins); expect(response.pinUpdates, isEmpty); }); test('a changed certificate is refused while the pin is valid', () async { server = FakeGeminiServer('gemini-test-b'); await server.start(); answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); final pins = { 'localhost:${server.port}': TofuPin(fingerprint: fixtureFingerprint('gemini-test-a'), expires: DateTime(2100)), }; await expectLater( client.fetch(await open('/', server), pins: pins), throwsA(isA()), ); }); test('a changed certificate renews an expired pin', () async { server = FakeGeminiServer('gemini-test-b'); await server.start(); answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); final pins = { 'localhost:${server.port}': TofuPin(fingerprint: fixtureFingerprint('gemini-test-a'), expires: DateTime(2020)), }; final response = await client.fetch(await open('/', server), pins: pins); expect(response.pinUpdates.single.pin.fingerprint, fixtureFingerprint('gemini-test-b')); }); test('a redirect is followed to the final address', () async { server = FakeGeminiServer('gemini-test-a'); await server.start(); final requests = []; answers = server.answer((line, socket) async { requests.add(line); if (line.endsWith('/')) { socket.add(utf8.encode('30 ${await open('/moved.gmi', server)}\r\n')); } else { socket.add(utf8.encode('20 text/gemini\r\narrived')); } }); final response = await client.fetch(await open('/', server)); expect(response.status, 20); expect(utf8.decode(response.body!), 'arrived'); expect(response.url.path, '/moved.gmi'); expect(requests, hasLength(2)); }); test('a redirect loop gives up after five hops', () async { server = FakeGeminiServer('gemini-test-a'); await server.start(); final requests = []; answers = server.answer((line, socket) async { requests.add(line); socket.add(utf8.encode('30 ${await open('/', server)}\r\n')); }); await expectLater( client.fetch(await open('/', server)), throwsA(isA()), ); expect(requests, hasLength(6)); }); test('a redirect away from gemini is returned rather than followed', () async { server = FakeGeminiServer('gemini-test-a'); await server.start(); answers = server.answer((line, socket) => socket.add(utf8.encode('30 https://example.com/elsewhere\r\n'))); final response = await client.fetch(await open('/', server)); expect(response.isRedirect, isTrue); expect(response.meta, 'https://example.com/elsewhere'); }); test('an unreachable host is one failure', () async { // A port with nothing listening on it, on a host that needs no DNS. final nowhere = Uri.parse('gemini://localhost:1/'); await expectLater(client.fetch(nowhere), throwsA(isA())); }); }