feat: add Gemini and Spartan protocol support

This commit is contained in:
randogoth 2026-10-11 22:07:25 +03:00
parent a4a49f41d7
commit fa7aa5557c
48 changed files with 4992 additions and 161 deletions

163
app/test/gemini_test.dart Normal file
View file

@ -0,0 +1,163 @@
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>()));
});
}