163 lines
5.5 KiB
Dart
163 lines
5.5 KiB
Dart
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<Uri> open(String path, FakeGeminiServer server) =>
|
|
Future.value(Uri.parse('gemini://localhost:${server.port}$path'));
|
|
|
|
void main() {
|
|
late FakeGeminiServer server;
|
|
late StreamSubscription<SecureSocket> 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<GeminiCertificateChanged>()),
|
|
);
|
|
});
|
|
|
|
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 = <String>[];
|
|
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 = <String>[];
|
|
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<GeminiTooManyRedirects>()),
|
|
);
|
|
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<GeminiException>()));
|
|
});
|
|
}
|