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

View file

@ -0,0 +1,103 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/controllers/browser_controller.dart';
import 'package:mews/application/key_store.dart';
import 'package:mews/database/database.dart';
import 'package:mews/protocols/tofu.dart';
import 'fake_fetch.dart';
void main() {
late MewsDatabase db;
late Directory keyDir;
late KeyStore keys;
setUp(() async {
db = MewsDatabase.test(NativeDatabase.memory());
keyDir = await Directory.systemTemp.createTemp('mews-keys');
keys = KeyStore(() async => keyDir);
});
tearDown(() async {
await db.close();
keyDir.deleteSync(recursive: true);
});
BrowserController browser(FakeFetch fetch) =>
BrowserController(db: db, fetch: fetch, keys: keys);
test('a bare hostname that answers on gemini is found there', () async {
final controller =
browser(FakeFetch({'gemini://capsule.example': '# Hello\n\nText.'}));
await controller.open('capsule.example');
expect(controller.error.value, isNull);
expect(controller.currentPage.value!.url, 'gemini://capsule.example');
expect(controller.currentPage.value!.title, 'Hello');
});
test('a bare hostname that answers on spartan is found there', () async {
final controller =
browser(FakeFetch({'spartan://capsule.example': '# Hello\n\nText.'}));
await controller.open('capsule.example');
expect(controller.error.value, isNull);
expect(controller.currentPage.value!.url, 'spartan://capsule.example');
});
test('an explicit scheme is not second-guessed', () async {
final controller =
browser(FakeFetch({'gemini://capsule.example': '# Hello\n\nText.'}));
// The reader named the web, so a missing web page is an error, not an
// invitation to try the capsule protocols.
await controller.open('https://capsule.example');
expect(controller.error.value, isA<LoadFailed>());
expect(controller.currentPage.value, isNull);
});
test('a changed certificate blocks until the reader trusts the new one', () async {
final presented = TofuPin(fingerprint: 'new', expires: DateTime(2036));
final controller = browser(FakeFetch(
{'gemini://capsule.example': '# Hello\n\nText.'},
changedCertificates: {'gemini://capsule.example': presented},
));
await controller.open('gemini://capsule.example');
expect(controller.error.value, isA<CertificateChanged>());
// Trusting records the presented certificate as the host's new pin.
await controller.trustPresentedCertificate();
expect(await db.allPins(), {'capsule.example:1965': presented});
});
test('a certificate request is answered by binding an identity', () async {
final controller = browser(FakeFetch(
{'gemini://capsule.example': '# Hello\n\nText.'},
certificateRequired: {'gemini://capsule.example'},
));
final id = await db.addClientCertificate('Reader', 'CERT PEM');
await keys.save(id, 'KEY PEM');
// The site asks before anything shows; the reader's answer is a choice,
// not an automatic presentation.
await controller.open('gemini://capsule.example');
expect(controller.error.value, isA<ClientCertificateRequired>());
await controller.chooseCertificate(id);
expect(await db.bindingFor('capsule.example', 1965), id);
expect(controller.error.value, isNull);
expect(controller.currentPage.value!.title, 'Hello');
// The binding is worn on the next visit too, without asking again.
await controller.open('gemini://capsule.example');
expect(controller.error.value, isNull);
expect(controller.currentPage.value!.title, 'Hello');
});
}

View file

@ -0,0 +1,211 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/key_store.dart';
import 'package:mews/database/database.dart';
import 'package:mews/l10n/strings.dart';
import 'package:mews/presentation/app.dart';
import 'fake_fetch.dart';
/// A PEM holding both the certificate and its key, the way a Gemini identity
/// commonly travels.
String combinedPem() =>
'${File('test/fixtures/client-test-cert.pem').readAsStringSync()}\n'
'${File('test/fixtures/client-test-key.pem').readAsStringSync()}';
/// Taps the run reading [label] inside a rendered paragraph; internal pages
/// put several links in one paragraph, so tapping the widget's center would
/// hit whichever run happens to sit in the middle.
Future<void> tapRun(WidgetTester tester, String label) async {
final paragraph = tester.renderObject<RenderParagraph>(
find
.descendant(
of: find.byWidgetPredicate((widget) =>
widget is Text &&
(widget.textSpan?.toPlainText() ?? widget.data ?? '')
.contains(label)),
matching: find.byType(RichText),
)
.first,
);
final plain = paragraph.text.toPlainText();
final start = plain.indexOf(label);
final box = paragraph
.getBoxesForSelection(
TextSelection(baseOffset: start, extentOffset: start + label.length))
.first;
await tester.tapAt(
paragraph.localToGlobal(Offset(box.left + 2, (box.top + box.bottom) / 2)));
await tester.pumpAndSettle();
}
void main() {
late MewsDatabase db;
late Directory keyDir;
late Strings strings;
late AppDependencies deps;
setUp(() async {
db = MewsDatabase.test(NativeDatabase.memory());
keyDir = await Directory.systemTemp.createTemp('mews-keys');
strings = Strings();
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
deps = AppDependencies(
database: db,
fetch: FakeFetch(const {}),
strings: strings,
keys: KeyStore(() async => keyDir),
pickPemFile: (title) async => (name: 'reader', content: combinedPem()),
);
});
tearDown(() async {
await db.close();
keyDir.deleteSync(recursive: true);
});
Future<void> pumpCertificates(WidgetTester tester) async {
await tester.pumpWidget(MewsApp(deps: deps, strings: strings));
await tester.pumpAndSettle();
await deps.browser.open('mews:certificates');
await tester.pumpAndSettle();
}
testWidgets('a certificate is imported, renamed and removed in a second step',
(tester) async {
await pumpCertificates(tester);
expect(find.textContaining('Certificates you add'), findsOneWidget);
// The pick is injected; its file name names the identity. The name shows
// in the row and prefills the rename form under it.
await tapRun(tester, 'Add a certificate');
expect(find.textContaining('reader'), findsWidgets);
expect(await db.allClientCertificates(), hasLength(1));
expect(await deps.keys.read(1), isNotEmpty);
// The address bar is a TextField too, so the form is the last one.
expect(find.byType(TextField), findsNWidgets(2));
expect(tester.widget<TextField>(find.byType(TextField).last).controller!.text,
'reader');
// Renaming happens in place, on the form under the identity's row.
await tester.enterText(find.byType(TextField).last, 'Reader');
await tester.tap(find.text('Save name'));
await tester.pumpAndSettle();
expect(find.textContaining('Reader'), findsWidgets);
expect((await db.allClientCertificates()).single.name, 'Reader');
// Removing asks first, the way every irreversible act here does.
await tapRun(tester, 'Remove');
expect(find.textContaining('stops presenting'), findsOneWidget);
await tapRun(tester, 'Keep it');
await tester.pumpAndSettle();
expect((await db.allClientCertificates()), hasLength(1));
await tapRun(tester, 'Remove');
await tapRun(tester, 'Remove the certificate');
expect(find.textContaining('Certificates you add'), findsOneWidget);
expect(await db.allClientCertificates(), isEmpty);
expect(await deps.keys.read(1), isNull);
});
testWidgets('a cancelled pick imports nothing', (tester) async {
final cancelledDeps = AppDependencies(
database: db,
fetch: FakeFetch(const {}),
strings: strings,
keys: KeyStore(() async => keyDir),
pickPemFile: (title) async => null,
);
await tester.pumpWidget(MewsApp(deps: cancelledDeps, strings: strings));
await tester.pumpAndSettle();
await cancelledDeps.browser.open('mews:certificates');
await tester.pumpAndSettle();
await tapRun(tester, 'Add a certificate');
expect(find.textContaining('Certificates you add'), findsOneWidget);
expect(await db.allClientCertificates(), isEmpty);
});
testWidgets('a key picked alone asks for its certificate', (tester) async {
final picks = <String>[];
final keyOnly = AppDependencies(
database: db,
fetch: FakeFetch(const {}),
strings: strings,
keys: KeyStore(() async => keyDir),
pickPemFile: (title) async {
picks.add(title);
if (picks.length == 1) {
// A .key file alone: the key, but no certificate to wear it in.
return (name: 'reader-key', content: File('test/fixtures/client-test-key.pem').readAsStringSync());
}
return (
name: 'reader-cert',
content: File('test/fixtures/client-test-cert.pem').readAsStringSync()
);
},
);
await tester.pumpWidget(MewsApp(deps: keyOnly, strings: strings));
await tester.pumpAndSettle();
await keyOnly.browser.open('mews:certificates');
await tester.pumpAndSettle();
await tapRun(tester, 'Add a certificate');
// The second pick was asked for by name, so the key was not silently
// dropped, and the pair imported together.
expect(picks, ['Choose a certificate', 'Choose the certificate that goes with the key']);
expect(await db.allClientCertificates(), hasLength(1));
expect(await keyOnly.keys.read(1), isNotEmpty);
expect(find.textContaining('reader-key'), findsWidgets);
});
testWidgets('a key picked alone with nothing after it imports nothing',
(tester) async {
final cancelled = AppDependencies(
database: db,
fetch: FakeFetch(const {}),
strings: strings,
keys: KeyStore(() async => keyDir),
pickPemFile: (title) async => title.contains('goes with')
? null
: (name: 'reader-key', content: File('test/fixtures/client-test-key.pem').readAsStringSync()),
);
await tester.pumpWidget(MewsApp(deps: cancelled, strings: strings));
await tester.pumpAndSettle();
await cancelled.browser.open('mews:certificates');
await tester.pumpAndSettle();
await tapRun(tester, 'Add a certificate');
// A key alone cannot be worn, and cancelling the second pick keeps
// nothing of it.
expect(await db.allClientCertificates(), isEmpty);
expect(await cancelled.keys.read(1), isNull);
expect(find.textContaining('Certificates you add'), findsOneWidget);
});
testWidgets('a certificate is presented only to the sites that ask for it',
(tester) async {
final id = await db.addClientCertificate('Reader', 'CERT PEM');
await db.bindCertificate('capsule.example', 1965, id);
await pumpCertificates(tester);
// The row names the site the identity is worn at.
expect(find.textContaining('capsule.example:1965'), findsOneWidget);
// Removing the identity takes the binding with it, so the site is left
// asking rather than pointing at a certificate that is no longer there.
await db.deleteClientCertificate(id);
await deps.browser.reload();
await tester.pumpAndSettle();
expect(find.textContaining('Certificates you add'), findsOneWidget);
expect(await db.bindingFor('capsule.example', 1965), isNull);
});
}

View file

@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:mews/database/database.dart';
import 'package:mews/mews/document.dart';
import 'package:mews/protocols/tofu.dart';
MewsEntry _entry(String url, {String title = 'A post', int day = 8}) =>
MewsEntry(url: url, date: DateTime(2026, 10, day), title: title);
@ -175,4 +176,41 @@ void main() {
await db.removeBookmark(url);
expect(await db.tagsOf(TagKind.bookmark, url), isEmpty);
});
test('pins are saved by host and port and replaced on renewal', () async {
final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030));
await db.savePin('capsule.example', 1965, pin);
await db.savePin('capsule.example', 1966, pin);
expect(await db.allPins(), {
'capsule.example:1965': pin,
'capsule.example:1966': pin,
});
// A renewal or a reader's new trust replaces the pin for that host.
final renewed = TofuPin(fingerprint: 'bb', expires: DateTime(2036));
await db.savePin('capsule.example:1965'.split(':').first, 1965, renewed);
expect((await db.allPins())['capsule.example:1965'], renewed);
});
test('bindings tie identities to hosts and follow the identity out', () async {
final id = await db.addClientCertificate('Reader', 'CERT PEM');
await db.bindCertificate('capsule.example', 1965, id);
// Binding again, not twice.
await db.bindCertificate('capsule.example', 1965, id);
expect(await db.bindingFor('capsule.example', 1965), id);
expect(await db.bindingsOf(id), ['capsule.example:1965']);
// Renaming an identity is its name only; where it is worn stays.
await db.renameClientCertificate(id, 'Reader Two');
expect((await db.allClientCertificates()).single.name, 'Reader Two');
expect(await db.bindingFor('capsule.example', 1965), id);
// Removing an identity takes its bindings with it, so no site is left
// pointing at a certificate that is no longer there.
await db.deleteClientCertificate(id);
expect(await db.allClientCertificates(), isEmpty);
expect(await db.bindingFor('capsule.example', 1965), isNull);
});
}

View file

@ -1,39 +1,100 @@
import 'dart:typed_data';
import 'package:actors/actors.dart';
import 'package:mews/application/actors/fetch_actor.dart';
import 'package:mews/mews/atom.dart';
import 'package:mews/mews/gemtext.dart';
import 'package:mews/mews/parser.dart';
import 'package:mews/protocols/tofu.dart';
/// Serves pages from an in-memory map without network or isolates, so
/// navigation tests are deterministic. [sources] wins over the cached source
/// a caller offers, the way a server answering 200 does, so a test can change
/// what a site publishes between two fetches. An address with no source is
/// unreachable, and the cached copy stands in for it if there is one.
///
/// A gemini or spartan address is served as gemtext, the way the real handler
/// routes protocol pages, and an address in [changedCertificates] refuses its
/// connection the way a changed server certificate does.
class FakeFetch implements Actor<FetchCommand, FetchResult> {
FakeFetch(this.sources, {this.feeds = const {}});
FakeFetch(this.sources,
{this.feeds = const {},
this.changedCertificates = const {},
this.certificateRequired = const {},
this.images = const {}});
final Map<String, String> sources;
/// Atom feeds by address, for sites that publish through one.
final Map<String, String> feeds;
/// Gemini addresses whose server certificate is not the pinned one, with
/// the pin of what was presented instead.
final Map<String, TofuPin> changedCertificates;
/// Gemini addresses that ask for a client certificate. They answer with a
/// certificate request until an identity is presented, then serve the page.
final Set<String> certificateRequired;
/// Raw bodies by address, the way a capsule serves an image.
final Map<String, Uint8List> images;
@override
Future<FetchResult> send(FetchCommand message) async {
return switch (message) {
ParseSource(:final url, :final source) => _parse(url, source),
FetchPage(:final url, :final cachedSource) => switch ((
sources[url],
cachedSource,
)) {
(final String fresh, _) => _parse(url, fresh),
(null, final String cached) => _parse(url, cached),
_ => const FetchNetworkError(),
FetchPage(:final url, :final cachedSource, :final clientCert) =>
_serve(url, cachedSource, clientCert),
SubmitForm(:final url, :final fields) =>
_serve(_withQuery(url, fields), null, null),
FetchBytes(:final url) => switch (images[url]) {
final Uint8List data => FetchBytesOk(data),
null => const FetchNetworkError(),
},
FetchFeed(:final url) => switch (feeds[url]) {
final String source =>
FetchFeedOk(url: url, entries: parseAtomEntries(source: source, feedUrl: url)),
null => const FetchNetworkError(),
},
};
}
/// An answer to a form on a protocol page carries the fields as the
/// address's query string; a web form posts to its action unchanged.
String _withQuery(String url, Map<String, String> fields) {
final uri = Uri.tryParse(url);
if (uri == null) return url;
if (uri.scheme != 'gemini' && uri.scheme != 'spartan') return url;
return uri.replace(queryParameters: {...uri.queryParameters, ...fields}).toString();
}
FetchResult _serve(String url, String? cachedSource, String? clientCert) {
final uri = Uri.tryParse(url);
if (uri != null && (uri.scheme == 'gemini' || uri.scheme == 'spartan')) {
if (certificateRequired.contains(url) && clientCert == null) {
return const FetchClientCertificateRequired(status: 60, meta: '');
}
final presented = changedCertificates[url];
if (presented != null) {
return FetchCertificateChanged(
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'gemini' ? 1965 : 300),
presented: presented);
}
return switch ((sources[url], cachedSource)) {
(final String fresh, _) => FetchPageOk(
page: parseGemtextPage(source: fresh, url: url), source: fresh),
(null, final String cached) => FetchPageOk(
page: parseGemtextPage(source: cached, url: url),
source: cached,
notModified: true),
_ => const FetchNetworkError(),
};
}
return switch ((sources[url], cachedSource)) {
(final String fresh, _) => _parse(url, fresh),
(null, final String cached) => _parse(url, cached),
_ => const FetchNetworkError(),
};
}

View file

@ -0,0 +1,93 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
/// Test doubles speaking the two protocols, so handler- and controller-level
/// tests can exercise the real fetch path against an in-process server.
/// A Gemini server answering every request from [respond], so a test can say
/// exactly what the client must cope with. [respond] gets the request line
/// and writes the raw response, status line and body together.
class FakeGeminiServer {
FakeGeminiServer(this.certName, {this.clientCertName});
final String certName;
/// The certificate this server asks the client for, the way a Gemini site
/// that wants an identity does. Dart's TLS verifies a client certificate
/// against the server's trust, so the test fixture is trusted by name.
final String? clientCertName;
late final int port;
late final SecureServerSocket server;
Future<void> start() async {
final context = SecurityContext()
..useCertificateChain('test/fixtures/$certName-cert.pem')
..usePrivateKey('test/fixtures/$certName-key.pem');
if (clientCertName != null) {
context.setTrustedCertificates('test/fixtures/$clientCertName-cert.pem');
}
server = await SecureServerSocket.bind('localhost', 0, context,
requestClientCertificate: clientCertName != null);
port = server.port;
}
StreamSubscription<SecureSocket> answer(FutureOr<void> Function(String, SecureSocket) respond) {
return server.listen((socket) async {
final line = await socket
.map(utf8.decode)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 5));
await respond(line, socket);
socket.destroy();
});
}
Future<void> close() => server.close();
}
/// A Spartan server answering every request from [respond], which gets the
/// request line and any uploaded bytes, and writes the raw response.
class FakeSpartanServer {
late final int port;
late final ServerSocket server;
Future<void> start() async {
server = await ServerSocket.bind('localhost', 0);
port = server.port;
}
StreamSubscription<Socket> answer(FutureOr<void> Function(String, List<int>, Socket) respond) {
return server.listen((socket) async {
final buffer = BytesBuilder(copy: false);
var lineEnd = -1;
await for (final chunk in socket) {
buffer.add(chunk);
final bytes = buffer.toBytes();
if (lineEnd < 0) lineEnd = _indexOfCrlf(bytes);
if (lineEnd < 0) continue;
final line = utf8.decode(bytes.sublist(0, lineEnd));
final length = int.parse(line.split(' ').last);
// Answer as soon as the line and any upload it announces are here;
// the client keeps the connection open waiting for the response.
if (bytes.length - lineEnd - 2 >= length) {
final upload = bytes.sublist(lineEnd + 2, lineEnd + 2 + length);
await respond(line, upload, socket);
socket.destroy();
return;
}
}
});
}
Future<void> close() => server.close();
}
int _indexOfCrlf(List<int> bytes) {
for (var i = 0; i + 1 < bytes.length; i++) {
if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i;
}
return -1;
}

View file

@ -1,7 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:actors/actors.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/actors/fetch_actor.dart';
import 'package:mews/mews/document.dart';
import 'fake_protocol_server.dart';
const page = '''
<?xml version="1.0" encoding="UTF-8"?>
@ -11,6 +18,8 @@ const page = '''
</html>
''';
const gemtext = '# Title\n\nSome text.\n';
void main() {
test('ParseSource parses without touching the network', () async {
final actor = Actor.create(FetchHandler.new);
@ -36,4 +45,166 @@ void main() {
expect(result, isA<FetchParseError>());
expect((result as FetchParseError).source, '<p>nope');
});
// The protocol transports are exercised through the same handler a fetch
// uses, against in-process servers, so these tests cover the whole path
// from command to page.
group('protocol dispatch', () {
final handler = FetchHandler();
StreamSubscription<Socket>? spartanAnswers;
StreamSubscription<SecureSocket>? geminiAnswers;
FakeSpartanServer? spartan;
FakeGeminiServer? geminiServer;
tearDown(() async {
await spartanAnswers?.cancel();
await geminiAnswers?.cancel();
await spartan?.close();
await geminiServer?.close();
});
test('a spartan page becomes a gemtext page', () async {
final server = FakeSpartanServer();
await server.start();
spartan = server;
spartanAnswers = server
.answer((line, upload, socket) => socket.add(utf8.encode('2 text/gemini\r\n$gemtext')));
final result = await handler.handle(FetchPage(url: 'spartan://localhost:${server.port}/'));
expect(result, isA<FetchPageOk>());
final ok = result as FetchPageOk;
expect(ok.page.title, 'Title');
expect(ok.page.isMews, isTrue);
expect(ok.page.body.first, isA<HeadingBlock>());
expect(ok.source, gemtext);
});
test('a spartan error status carries its message', () async {
final server = FakeSpartanServer();
await server.start();
spartan = server;
spartanAnswers = server
.answer((line, upload, socket) => socket.add(utf8.encode('4 Nothing lives here\r\n')));
final result =
await handler.handle(FetchPage(url: 'spartan://localhost:${server.port}/gone'));
expect(result, isA<FetchProtocolError>());
expect((result as FetchProtocolError).message, 'Nothing lives here');
});
test('a gemini input request becomes a one-field form', () async {
final server = FakeGeminiServer('gemini-test-a');
await server.start();
geminiServer = server;
geminiAnswers =
server.answer((line, socket) => socket.add(utf8.encode('10 Enter a word\r\n')));
final result =
await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/ask'));
expect(result, isA<FetchPageOk>());
final form = (result as FetchPageOk).page.body.single as FormBlock;
final field = form.fields.single as MewsTextField;
expect(field.name, 'query');
expect(field.password, isFalse);
expect(field.label, 'Enter a word');
});
test('a sensitive gemini input asks quietly', () async {
final server = FakeGeminiServer('gemini-test-a');
await server.start();
geminiServer = server;
geminiAnswers = server.answer((line, socket) => socket.add(utf8.encode('11 A secret\r\n')));
final result =
await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/ask'));
final form = (result as FetchPageOk).page.body.single as FormBlock;
expect((form.fields.single as MewsTextField).password, isTrue);
});
test('the first visit to a gemini host reports a pin to save', () async {
final server = FakeGeminiServer('gemini-test-a');
await server.start();
geminiServer = server;
geminiAnswers = server
.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n$gemtext')));
final result = await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/'));
final ok = result as FetchPageOk;
expect(ok.pinUpdates, hasLength(1));
expect(ok.pinUpdates.single.host, 'localhost');
expect(ok.pinUpdates.single.port, server.port);
});
test('a gemini site asking for a certificate gets the bound identity', () async {
final server = FakeGeminiServer('gemini-test-a', clientCertName: 'client-test');
await server.start();
geminiServer = server;
geminiAnswers = server.answer((line, socket) {
// The server asks but never checks: an identity is offered or not.
if (socket.peerCertificate == null) {
socket.add(utf8.encode('60 Who are you\r\n'));
} else {
socket.add(utf8.encode('20 text/gemini\r\n$gemtext'));
}
});
// Without an identity, the request comes back to the reader as a
// choice to make.
final asked =
await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/'));
expect(asked, isA<FetchClientCertificateRequired>());
expect((asked as FetchClientCertificateRequired).status, 60);
// With the identity, the page arrives wearing it.
final cert = File('test/fixtures/client-test-cert.pem').readAsStringSync();
final key = File('test/fixtures/client-test-key.pem').readAsStringSync();
final ok = await handler.handle(FetchPage(
url: 'gemini://localhost:${server.port}/', clientCert: cert, clientKey: key));
expect(ok, isA<FetchPageOk>());
expect((ok as FetchPageOk).page.title, 'Title');
});
test('a capsule image arrives as bytes', () async {
final server = FakeSpartanServer();
await server.start();
spartan = server;
final png = base64.decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
spartanAnswers = server.answer((line, upload, socket) => socket.add([
...utf8.encode('2 image/png\r\n'),
...png,
]));
final result = await handler
.handle(FetchBytes(url: 'spartan://localhost:${server.port}/dot.png'));
expect(result, isA<FetchBytesOk>());
expect((result as FetchBytesOk).data, png);
});
test('a gemini form answers as a query string', () async {
final server = FakeGeminiServer('gemini-test-a');
await server.start();
geminiServer = server;
var asked = '';
geminiAnswers = server.answer((line, socket) {
asked = line;
socket.add(utf8.encode('20 text/gemini\r\n$gemtext'));
});
final result = await handler.handle(SubmitForm(
url: 'gemini://localhost:${server.port}/ask',
method: 'get',
fields: {'query': 'phones'}));
// The fake reads whole lines, so the CRLF is already stripped.
expect(asked, 'gemini://localhost:${server.port}/ask?query=phones');
expect(result, isA<FetchPageOk>());
});
});
}

17
app/test/fixtures/client-test-cert.pem vendored Normal file
View file

@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICsjCCAZqgAwIBAgIBWjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDEwZyZWFk
ZXIwHhcNMjYxMDExMTI0MjE1WhcNMzYxMDA4MTI0MjE1WjARMQ8wDQYDVQQDEwZy
ZWFkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPWGGLKgFsrVVJ
lB4Xnk2YqiWeJ+KgDr2wikdIJ6kA0D6pM0+fnDCQhJAztsLQ6XMEdOBpC8gB8Vrj
o2Zshg4BD45HCb848mYgwjlCR+oCmaRBV4HhuOZk7JNOS/VHCINRpN9WPSr2UDRs
aXKvYosuz92b3g537N2Pp3x2guCGPQ8svIpv0xmF97IR0cX+1VTQ1fvpwu25CxPl
xnXlHtX5Dif7mNhb1H9gkx1/yamSpVaeH9mAX/4DmJmFOY95oUybK+yIqSwIVH2g
EMuTtqxaMMlph7faodJD6vDb1g5yNUfwDG/MVtHRgPx+47FTTd3wG8t+4Blsytpj
q0oI51vLAgMBAAGjFTATMBEGA1UdEQQKMAiCBnJlYWRlcjANBgkqhkiG9w0BAQsF
AAOCAQEAgn0/ifmoPo8ecKzpoHuYXRso9M3t/+rmvFTRri+NBSBzehIXdF46Yynw
imqEAZpr5eRIIIE/0J1///LbVcRMe0KOwdlJrM+AZOS6CtHuxLTspvYcLLjRPV4B
/asZnTEZMgZTX9kbisiQ9sFW9qekqvDIDM3Eu4/AYpA+Khbmp+O1+1r6dNOESIQl
YxtnbRxVLvNbxXxNxbAH7bXLixW3+9s3LaZ3mfQDwU3rr/yzctFk3W4b7pYKuWyQ
CSOYF7CF15ClflIs9jsejNrL3x6pwkJRRmAZYRY7nDdzF0K5LawD8jtLV9cwqOr4
LEE9eq3AfaIN8VldEMmt/WohrwYtdQ==
-----END CERTIFICATE-----

28
app/test/fixtures/client-test-key.pem vendored Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCPWGGLKgFsrVVJ
lB4Xnk2YqiWeJ+KgDr2wikdIJ6kA0D6pM0+fnDCQhJAztsLQ6XMEdOBpC8gB8Vrj
o2Zshg4BD45HCb848mYgwjlCR+oCmaRBV4HhuOZk7JNOS/VHCINRpN9WPSr2UDRs
aXKvYosuz92b3g537N2Pp3x2guCGPQ8svIpv0xmF97IR0cX+1VTQ1fvpwu25CxPl
xnXlHtX5Dif7mNhb1H9gkx1/yamSpVaeH9mAX/4DmJmFOY95oUybK+yIqSwIVH2g
EMuTtqxaMMlph7faodJD6vDb1g5yNUfwDG/MVtHRgPx+47FTTd3wG8t+4Blsytpj
q0oI51vLAgMBAAECggEAFGBX3VRnR2+MRB1N+cWC+kSVfFnnGSF/TW0anOMmXvs7
LQoiM2ocbS6q4O8F2nSU6PlEPSpzj/MjHGDJ2ntj2jq/2tFD4XBznAx/3zAhz6TP
eIpb6B5EgewvNF4Py70mDaEGLlgbl0/nY9HJf6zeZBUgR1vVaq69AAKq6p3IFMy5
JzuL9kgxjidqVrGYIJ8Vp/FA/WVFLjsUnMJNhBtM4Ldc+nxUiq/9PQS1+E6KUr9+
Q+F1PlGQpX71Txwnpnkb/41hjh+EEUiXTHXT+Xlw1toIiC8hk2fqCp2/pxyKd2EQ
PJHB+afLcFO8heZi4r9owqLXYDZeyGPtCWhonkPjwQKBgQDzbGkdklJ6LrLzts/m
dp+o6Bg7dFDq1HZA/F+iR/rCJKfnv1Nw9odEYA/bfQuNf9Tue71Ze33WXGzFXb4H
4Od4IJCXrMn7leZ8OaLIaIHZbxhvLU2CItfanMOyhh02irUDh4t0mdQ7Om3SetCR
NxiaKUL6J895F/EnV1l3BGUZuQKBgQCWwE6ARULaTXWIqEgkuEQCHKcVPjRGue0C
nugjWTBcfDz8JSb7s+D2fsFjukrXx1wx9jSF9feeD5rE7bwyHiVfSae4ydDW85DV
Z2NWnbPTdbu1S/R5I1ut8CUXutd3njLJCO4PcDxR/2j8rz9mpYsDv35Msc9AoGZb
E6T6EzlTowKBgQDOPGpukHrqY8hf7eIEizpIG6P0M18fJjSuaGQKFNviQrYzBYmH
aoc+KfklZ7MzBjv8db/+IT+DLzlEmmCXQjg6MbTvnIhgbCcj1p0RGGHzO0nDX+mM
OUMSR8ER7sivXiNwwxUnC/Gg4KcNLdqpPbnNWuoREUuZd+xr/Xih+opBmQKBgG9d
NSCxCcf2/OKtXk4yCZt9yzh4MWomI56SAW9LjdxkszCZyYibw46U6QxhLLTmw4bt
GLP1lq0fXfgon6RufLE92jc6b5q7Z70emVX9PkDycm5l4/4xTqLaCqzTDCWDqoS0
ohipkwYIJmi0laOgm2dzaTwDBtX5KGQTyQYseWb9AoGAY4ctZu3vHl2Pbg2zl53E
lD/n5zSPBRjfbZKJVfiWvC2+ut5YX7dWNKiVmurS9XPRQCTcujOYNxjbzB+t5Bcm
AQpSnwKe36wWyrH3YZkGmHxsAtxi1tUQVZxTX13NfhqYWAyxTxIrIUwXwabfTH7G
CB8KWfIABVIG4nWJDE/kRIQ=
-----END PRIVATE KEY-----

View file

@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICxjCCAa6gAwIBAgIBATANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwlsb2Nh
bGhvc3QwHhcNMjYxMDExMTIxMjI1WhcNMzYxMDA4MTIxMjI1WjAUMRIwEAYDVQQD
Ewlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBNxVR
zQ0tgS4HmmpbsOFF4n0wip6fZKolD7Kif1vKKuzj9/+3cIiChGixmPNu4WtcIuf6
8HvLDBcp/uoDDroALe6Y9eoAsx2ibHGFuujOhTT/fAe87GYqIMTttSuOeP2VGauT
hcHiQIB25UB7ejUUPtgk/+9b/dQpZOrFonb2FjAASy3Sxp8uhflz/D42yz/8Bes0
0Z3ZihZeaJTTWX3fPmHbK9X5ZjjbtpIbiE+leBWQ1Kt5zuucq5tERBSDEXSw16Wk
vKubKe+t249L1QsCIg4t11v+BbY2SuvT9K5uNatxAEeNCVGWdghwQ95XaG9XrybM
05rSPGPylZogSSXNAgMBAAGjIzAhMB8GA1UdEQQYMBaCCWxvY2FsaG9zdIIJMTI3
LjAuMC4xMA0GCSqGSIb3DQEBCwUAA4IBAQAySnYV9S5ARkwXfSCYPC9m8nmDnaIa
5iqCMmZRdGVTj/jR653s3QJZMkYSaN7UnALxTgdb8pNASgca+HuM5PBhxrX2wmI+
4ocMwbdFrPPEi5gpIBZs6acGzX9KtpOOOOaNcz0+AsbcvyYFZLxVcdiFkcbBL1HM
jw6rqAxTvjyhSJMGcf/Es3SL/WffF3tDmumOyhN4BVgzKoQyMF+Ei0Pija+yoGrN
pyQJm0RYDEzYiw7DGUMSC8BoTj5yIUETWvNkRK78WDu1Kb5Fq3RqYAasd/wRKEfX
4dsZmmoH35tfGI0xMc2GnnMC8c7g7p/dMJdIOiupcg6/1sgqGTH5Tvb9
-----END CERTIFICATE-----

28
app/test/fixtures/gemini-test-a-key.pem vendored Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDBNxVRzQ0tgS4H
mmpbsOFF4n0wip6fZKolD7Kif1vKKuzj9/+3cIiChGixmPNu4WtcIuf68HvLDBcp
/uoDDroALe6Y9eoAsx2ibHGFuujOhTT/fAe87GYqIMTttSuOeP2VGauThcHiQIB2
5UB7ejUUPtgk/+9b/dQpZOrFonb2FjAASy3Sxp8uhflz/D42yz/8Bes00Z3ZihZe
aJTTWX3fPmHbK9X5ZjjbtpIbiE+leBWQ1Kt5zuucq5tERBSDEXSw16WkvKubKe+t
249L1QsCIg4t11v+BbY2SuvT9K5uNatxAEeNCVGWdghwQ95XaG9XrybM05rSPGPy
lZogSSXNAgMBAAECggEACFNfT88OKk/Os/UrcBgHHQPNcTIH88Zaro4mnGnwgp2m
54vgMP9gYn9iI4T7uGNuROIcLOv/6Zfzx5N8cfVIfol8JosyMiWFuWIC4khTj9Kd
ngSyMKnfHSGcWsHbxp/gDXS1t/2jhSRSZM0sIiX5841hsvZ0Ufp2EfKPS0Tc622m
KRgN+EfM9cLuknon7L3YcK2dYhMp1ZslzDYkIjU97ydHxtZ5Xn0Zd+HGFueaWzon
zP94nx31wRSbzGF1nQ+7ESbkB2pGtP+xRva/sAlXUTRDLV3Krr5paqLWMU+law+j
PeZay5aH0go7yfLuRsxavQ/A0xHw0Tl9PoL0ikZb4QKBgQDer9OzsddE1pkTq7HR
ttX3Xg2RQoDBgFVDtrbQOm+RUOqw4NNjJVah8zx2ZSwc75PMR4WcjgLOl7JwH7Eg
3eFLFknJbGaFvkoMhKLmA6PnXFs7rX7cB3uusf2VCP5oFXiRltwOY6XxfYHyVGTw
HUfVx1eVUy0LFPNKX/fKEhWIewKBgQDeHpa+/lOusGk9xJlBFkd891j6+MsROB7C
CIQMXJiv9wKQg5G4uV3qlMiddsApwFxlpXoPxjcA0rgureNFAM4/R7PTq8yHG2NO
nUDCxkkhPb8sR7D4qZFEVvQQEjqPWQFsv+OdykPh29lzSDmPyu5rv0FHP2IKA2wM
hNHAWhYMVwKBgHSKXCa8ucZC8NXEk0kHgK72v+73eN7LxNOtObt7+Yt5Z3muD7Lh
hZc37rqfdBiooRbVj21BPOLVrQLU8yQaOp/LSE5rtrPjxGkMuRvbZ9nbEbwHrk6E
dAwXBnkKvenG4tR+uSemqt/ZKTqjRDgNety8eG+jmf0kt7aCxvEvyaklAoGBAIjd
8meoEWqAG6lK/mBD2TMguvyS15Y5rYJEexGe8Rrkqvjfiah42G0W1vNoXAqeFJEx
wR/9NKFWNtXUokk/sNb/WMiigPnzn2KutVwPkLph8O3ZEyx2WCPzLJrGkL0JzD31
k80D+IYw0TIfHBEFq5pLFG9RmBixeR/Rq+nJ2PIrAoGBANbGlg1YZHZloev+nhOO
0TiaS0GwlnHAvss228m+V2rXkX1niH6JbVCDIQ+XxzG25Tj+UFnoSdBTnpajdZzX
5IPUenRh6wTOxKPjHj7Zvhwxx+RL9xzwudBBPC3jTJLqruTg1cw24ux7b+LDO0wG
pYXQqTjqhtdw4XTH10JQdAIj
-----END PRIVATE KEY-----

View file

@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICxjCCAa6gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwlsb2Nh
bGhvc3QwHhcNMjYxMDExMTIxMjI1WhcNMzYxMDA4MTIxMjI1WjAUMRIwEAYDVQQD
Ewlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQPvJZ
SEze1FQQLOzsRDMmVnRttzrLC7MxR8kVk4DMrN2Dub6/1siyn74oQI3IMYOV2JRn
MC6S5ejy0y/5F3eeZ/n1xgiUOuWi8Rg/tS74WweLY/dcn5cVhibrSmEBJtYxt0KO
3t2s6A+haK8JKztm8V7wuSHbdkjpCCMoJj175SLuCyiF0iwD80mAM4WG0OmVIXtX
lyJ/5DwTBlb0XQMXBhmRe5v95m9UanxYef9aG03LPm8pjl2EWDEzK/25mNSI5eGv
AnniRhxHtp0sXoAi6oN0sTX3eCAru2/5WdjpOan6YLsXIsRmYizDevTX/tiB18al
152YJ0Bor4QI0xARAgMBAAGjIzAhMB8GA1UdEQQYMBaCCWxvY2FsaG9zdIIJMTI3
LjAuMC4xMA0GCSqGSIb3DQEBCwUAA4IBAQCFnr7gLYsnjraD8NRtguLEElJtTKIe
L8KW9Inil43CxJ5BF+p1IcExnZmcedRljGVES7tJ8sZDX5/VnUhErkjoy1uZjAIZ
3VVFXgKkH170HsSQv+SnRsUr/vfcqvI2AKr2Dapy+L4ftUYR9+0uXmSRXKTP2kQK
5wd2B2vjX4DMRrEiClQ2R4+bbut7bRokIr4X6NXVoEBuAZWYTGQK5Dydhg4m/t0o
NE8VC0qB2xhHxIhU5Q9PSpHuL8KJr0zJ++53X+LHfhy2fgN+ia/cqLaYszqbvQDQ
8lRGVUmpvQ+nYpvgYMmluEHJtbFGnSfvNBXsJGP9EqwN6ZiQBzNwWmRE
-----END CERTIFICATE-----

28
app/test/fixtures/gemini-test-b-key.pem vendored Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCQPvJZSEze1FQQ
LOzsRDMmVnRttzrLC7MxR8kVk4DMrN2Dub6/1siyn74oQI3IMYOV2JRnMC6S5ejy
0y/5F3eeZ/n1xgiUOuWi8Rg/tS74WweLY/dcn5cVhibrSmEBJtYxt0KO3t2s6A+h
aK8JKztm8V7wuSHbdkjpCCMoJj175SLuCyiF0iwD80mAM4WG0OmVIXtXlyJ/5DwT
Blb0XQMXBhmRe5v95m9UanxYef9aG03LPm8pjl2EWDEzK/25mNSI5eGvAnniRhxH
tp0sXoAi6oN0sTX3eCAru2/5WdjpOan6YLsXIsRmYizDevTX/tiB18al152YJ0Bo
r4QI0xARAgMBAAECggEADy2QcrSY4hmVft47T+EdQLt4JNNS7GqewGC9V/fdZawK
4wF4gNco5yLH75RbVuVe4zWW6AS54dsvV93A/oTpoVFvi9z6qh5Ia4zS8GEwQBHU
CWbLcIOacjlbfwakjmp3v2N5LW9mA/unKWTKhjx2U66yPsI8PcuEgIDKsj7T2WVq
hqT2slp0sB96y6HyIo56Wwi/DFp5Jm05n2+DqaI9OzdYsvF5XgErLRENmWmTyd8/
N/Z/VhrJZXzmTx08fT1FoMjcyEr2km8SafX/U7TH9fV1SWqzNi/wHH0i/664IEkF
iiwDD9ArSu0PxoQ9g+IDOsS4PUifyH3ZwkNTUmvpAQKBgQDAqeGE2iWEdNFS8nZF
z76TArrqcuJwVxAyEfxrznilamu2/L0DEE9WVfGFNuenWl8mD6aZ4mmH7dHpdxH1
pNem+KelrKYyADTREDRWIotb8/xnbRpz3OmVbbMxf/XU+4qREvg9koCiXwD0Jzb1
eF+V6dN13EQAmRKrosnITixWnQKBgQC/qlgRSHBnBJVhpjaASlpSp6sXiOGiFsXI
MpfDAzkMZSgyroBeJQg0Yc4ZyWXmbFF/X1DhSIzMEwYjppZVgH6ME5Di3wditU96
/iz+zt6Wa5xRh0rbBHaMqChWWdi8sHZ0QaI1NYQFs1H5hFubSRGhv4SAOv4K1sdI
JaJoXD0rBQKBgDMFCbaPuj2wXbpUgOON1w7BO1RYpgRP2fcYcjruS16qpumnVjpY
eFoJmlLHrmLP+MBwhpLbtUyMuvjTUks1ovIRiPvDk6NMELCIVQO2VCc+nkmZR9cE
2kp66WgjVatYDWLLnjdX836MIAmH1PL+NHnTmO7uLIfoAVy4WC0WHoG1AoGBAIqh
wJ+HoN2ywAXofmVe4mQEqMkWFfXyuM4sYf8JFrtzc5btByaRbJTO5Y9bUBxR3sTd
gZfhrQ1y+S7SMMzZay8Qe+zNWv83m6yfqpYHtZJhdai2zhsK6GCmxL5osE8S6CsA
yj3oXnztDm8HIGd6e4leCI3jnZrjbTMvHYCUrx2tAoGBAK/8HO7FMu14z16C9yv9
L1Bxi51n7xe0llojR1nv+UzKE1BaG3u9Ni3kNiJGGZ21B4TS3nlWS6D3CZOMsH10
t5fqOCKdGY1eUxRu0qKa2jMn/lbdszevsDFsry++6xf4ZgcWB9++4FJupchf2Jet
U8bDDYMHS3yhAw/hOX8Q/Vy4
-----END PRIVATE KEY-----

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>()));
});
}

109
app/test/gemtext_test.dart Normal file
View file

@ -0,0 +1,109 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/mews/document.dart';
import 'package:mews/mews/gemtext.dart';
const page = '''
# A capsule
Some opening text.
A second line of it.
## A section
* one
* two
=> index.gmi The index
=> retro.gmi 2026-10-08 Collecting retro phones
=> first.gmi 2026-09-21 First post
> a quoted line
> and another
```rust
fn main() {}
```
#### four hashes are text
''';
void main() {
test('gemtext converts to the document model', () {
final parsed = parseGemtextPage(source: page, url: 'gemini://capsule.example/index.gmi');
expect(parsed.url, 'gemini://capsule.example/index.gmi');
expect(parsed.specVersion, gemtextSpecVersion);
expect(parsed.isMews, isTrue);
expect(parsed.title, 'A capsule');
expect(parsed.direction, TextDirection.ltr);
final headings = parsed.body
.whereType<HeadingBlock>()
.map((h) => (h.level, (h.inline.single as MewsText).text))
.toList();
expect(headings, [
(1, 'A capsule'),
(2, 'A section'),
]);
// Each text line is its own paragraph.
final paragraphs = parsed.body.whereType<ParagraphBlock>().length;
expect(paragraphs, 3); // Two opening lines and the four-hashes line.
final lists = parsed.body.whereType<ListBlock>().toList();
expect(lists, hasLength(2));
expect(lists.first.items, hasLength(2));
expect(
((lists.first.items.first.blocks.single as ParagraphBlock).inline.single as MewsText).text,
'one');
// Consecutive link lines form one list, so a menu reads as a menu.
expect(lists.last.items, hasLength(3));
final quotes = parsed.body.whereType<QuoteBlock>().toList();
expect(quotes, hasLength(1));
expect(quotes.first.children, hasLength(2));
final pre = parsed.body.whereType<PreBlock>().single;
expect(pre.text, 'fn main() {}');
});
test('dated links become entries, resolved absolute', () {
final parsed = parseGemtextPage(source: page, url: 'gemini://capsule.example/index.gmi');
expect(
parsed.entries.map((e) => (e.url, e.date, e.title)).toList(),
[
('gemini://capsule.example/retro.gmi', DateTime(2026, 10, 8), 'Collecting retro phones'),
('gemini://capsule.example/first.gmi', DateTime(2026, 9, 21), 'First post'),
],
);
});
test('a link without a label shows its address', () {
final parsed = parseGemtextPage(
source: '=> gemini://else.example/x.gmi', url: 'gemini://capsule.example/');
final list = parsed.body.whereType<ListBlock>().single;
final link = ((list.items.single.blocks.single as ParagraphBlock).inline.single as MewsLink);
expect(link.href, 'gemini://else.example/x.gmi');
expect((link.children.single as MewsText).text, 'gemini://else.example/x.gmi');
});
test('an unterminated pre block keeps its lines', () {
final parsed = parseGemtextPage(
source: '```\nstill inside', url: 'gemini://capsule.example/');
expect(parsed.body.whereType<PreBlock>().single.text, 'still inside');
});
test('right-to-left text is written in its own direction', () {
final parsed = parseGemtextPage(
source: '# שלום\nטקסט בעברית', url: 'gemini://capsule.example/he.gmi');
expect(parsed.direction, TextDirection.rtl);
expect(parsed.lang, 'he');
expect(parsed.title, 'שלום');
});
}

View file

@ -0,0 +1,33 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/key_store.dart';
void main() {
late Directory dir;
late KeyStore keys;
setUp(() async {
dir = await Directory.systemTemp.createTemp('mews-keys');
keys = KeyStore(() async => dir);
});
tearDown(() {
dir.deleteSync(recursive: true);
});
test('a key is stored, read back and removed', () async {
expect(await keys.read(7), isNull);
await keys.save(7, 'KEY PEM');
expect(await keys.read(7), 'KEY PEM');
// Saving again replaces, the way a renewed identity replaces its key.
await keys.save(7, 'NEW KEY PEM');
expect(await keys.read(7), 'NEW KEY PEM');
await keys.delete(7);
expect(await keys.read(7), isNull);
});
}

View file

@ -1,4 +1,6 @@
import 'dart:convert' show base64;
import 'dart:io' show File;
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
@ -24,7 +26,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Enter a web address'), findsOneWidget);
expect(find.text('Enter an address'), findsOneWidget);
// The hub opens from the left icon; no floating button remains.
expect(find.byIcon(Icons.menu), findsOneWidget);
expect(find.byType(FloatingActionButton), findsNothing);
@ -52,7 +54,7 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: StringsScope(
strings: (await _strings()),
strings: await _strings(),
child: MewsPageView(
page: page,
palette: paletteFor(
@ -77,6 +79,84 @@ void main() {
await tester.pump();
expect(opened, 'https://example.com/next.html');
});
testWidgets('an image on a capsule arrives over the protocol', (tester) async {
// A 1x1 PNG, small enough to spell out.
final png = Uint8List.fromList(base64.decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='));
final dot = MewsPage(
url: 'gemini://capsule.example/dot.png',
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
specVersion: 'gemtext',
entries: const [],
body: const [
ImageBlock(src: 'gemini://capsule.example/dot.png', alt: 'A dot', allowed: true),
],
);
Future<Uint8List?> load(String url) async =>
url == 'gemini://capsule.example/dot.png' ? png : null;
await tester.pumpWidget(
MaterialApp(
home: StringsScope(
strings: await _strings(),
child: MewsPageView(
page: dot,
palette: paletteFor(
pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false),
fontFamily: 'Atkinson Hyperlegible',
fontScale: 1.0,
visitedUrls: const {},
onLinkTap: (_) {},
onFormSubmit: (_, _) {},
fetchImageBytes: load,
),
),
),
);
await tester.pumpAndSettle();
// The image itself, decoded from the protocol's bytes.
expect(find.byType(Image), findsOneWidget);
// An image that never arrives falls back to its alt text, the way a
// missing image on the web does.
final missing = MewsPage(
url: 'gemini://capsule.example/none.png',
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
specVersion: 'gemtext',
entries: const [],
body: const [
ImageBlock(src: 'gemini://capsule.example/none.png', alt: 'A dot', allowed: true),
],
);
await tester.pumpWidget(
MaterialApp(
home: StringsScope(
strings: await _strings(),
child: MewsPageView(
page: missing,
palette: paletteFor(
pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false),
fontFamily: 'Atkinson Hyperlegible',
fontScale: 1.0,
visitedUrls: const {},
onLinkTap: (_) {},
onFormSubmit: (_, _) {},
fetchImageBytes: load,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(Image), findsNothing);
expect(find.textContaining('could not be loaded'), findsOneWidget);
});
}
Future<Strings> _strings() async {

129
app/test/spartan_test.dart Normal file
View file

@ -0,0 +1,129 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'fake_protocol_server.dart';
import 'package:mews/protocols/spartan.dart';
Uri open(String path, FakeSpartanServer server) =>
Uri.parse('spartan://localhost:${server.port}$path');
void main() {
late FakeSpartanServer server;
late StreamSubscription<Socket> answers;
final client = SpartanClient(timeout: const Duration(seconds: 5));
tearDown(() async {
await answers.cancel();
await server.close();
});
test('a success answers with status, meta and body', () async {
server = FakeSpartanServer();
await server.start();
var requested = '';
answers = server.answer((line, upload, socket) {
requested = line;
socket.add(utf8.encode('2 text/gemini\r\nhello'));
});
final response = await client.fetch(open('/page', server));
expect(response.status, 2);
expect(response.meta, 'text/gemini');
expect(utf8.decode(response.body!), 'hello');
expect(requested, 'localhost /page 0');
});
test('an upload rides behind the request line', () async {
server = FakeSpartanServer();
await server.start();
var requested = '';
var uploaded = '';
answers = server.answer((line, upload, socket) {
requested = line;
uploaded = utf8.decode(upload);
socket.add(utf8.encode('2 text/gemini\r\nok'));
});
final response =
await client.fetch(open('/echo', server), upload: utf8.encode('payload'));
expect(response.status, 2);
expect(requested, 'localhost /echo 7');
expect(uploaded, 'payload');
});
test('a query is part of the path', () async {
server = FakeSpartanServer();
await server.start();
var requested = '';
answers = server.answer((line, upload, socket) {
requested = line;
socket.add(utf8.encode('2 text/gemini\r\nok'));
});
await client.fetch(open('/search?q=phones', server));
expect(requested, 'localhost /search?q=phones 0');
});
test('a redirect is followed to the final address', () async {
server = FakeSpartanServer();
await server.start();
final requests = <String>[];
answers = server.answer((line, upload, socket) async {
requests.add(line);
if (line.contains('/here')) {
socket.add(utf8.encode('3 ${open('/there', server)}\r\n'));
} else {
socket.add(utf8.encode('2 text/gemini\r\narrived'));
}
});
final response = await client.fetch(open('/here', server));
expect(response.status, 2);
expect(utf8.decode(response.body!), 'arrived');
expect(response.url.path, '/there');
expect(requests, hasLength(2));
});
test('a redirect loop gives up after five hops', () async {
server = FakeSpartanServer();
await server.start();
final requests = <String>[];
answers = server.answer((line, upload, socket) async {
requests.add(line);
socket.add(utf8.encode('3 ${open('/loop', server)}\r\n'));
});
await expectLater(
client.fetch(open('/loop', server)),
throwsA(isA<SpartanTooManyRedirects>()),
);
expect(requests, hasLength(6));
});
test('an error status carries its message', () async {
server = FakeSpartanServer();
await server.start();
answers = server.answer((line, upload, socket) =>
socket.add(utf8.encode('4 Nothing lives here\r\n')));
final response = await client.fetch(open('/gone', server));
expect(response.status, 4);
expect(response.meta, 'Nothing lives here');
expect(response.body, isNull);
});
test('an unreachable host is one failure', () async {
final nowhere = Uri.parse('spartan://localhost:1/');
await expectLater(client.fetch(nowhere), throwsA(isA<SpartanException>()));
});
}

43
app/test/tofu_test.dart Normal file
View file

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/protocols/tofu.dart';
void main() {
test('a first visit accepts and pins what was presented', () {
final now = DateTime(2026, 10, 11);
expect(decideTofu(null, 'aa', now), TofuDecision.firstUse);
});
test('the pinned certificate is accepted again', () {
final now = DateTime(2026, 10, 11);
final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030));
expect(decideTofu(pin, 'aa', now), TofuDecision.match);
});
test('a change is a renewal when the pin has expired', () {
final now = DateTime(2026, 10, 11);
final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2020));
expect(decideTofu(pin, 'bb', now), TofuDecision.rePin);
});
test('a change is refused while the pin is still valid', () {
final now = DateTime(2026, 10, 11);
final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030));
expect(decideTofu(pin, 'bb', now), TofuDecision.refuse);
});
test('a pin that ends exactly now counts as expired', () {
final now = DateTime(2026, 10, 11);
final pin = TofuPin(fingerprint: 'aa', expires: now);
expect(decideTofu(pin, 'bb', now), TofuDecision.rePin);
});
test('a fingerprint comes from the DER encoding', () {
final pin = pinOf([1, 2, 3], expires: DateTime(2030));
// sha256 of the three bytes, as lowercase hex.
expect(
pin.fingerprint,
'039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81',
);
});
}