feat: add Gemini and Spartan protocol support
This commit is contained in:
parent
a4a49f41d7
commit
fa7aa5557c
48 changed files with 4992 additions and 161 deletions
103
app/test/browser_controller_test.dart
Normal file
103
app/test/browser_controller_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
211
app/test/certificates_page_test.dart
Normal file
211
app/test/certificates_page_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
93
app/test/fake_protocol_server.dart
Normal file
93
app/test/fake_protocol_server.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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
17
app/test/fixtures/client-test-cert.pem
vendored
Normal 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
28
app/test/fixtures/client-test-key.pem
vendored
Normal 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-----
|
||||
17
app/test/fixtures/gemini-test-a-cert.pem
vendored
Normal file
17
app/test/fixtures/gemini-test-a-cert.pem
vendored
Normal 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
28
app/test/fixtures/gemini-test-a-key.pem
vendored
Normal 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-----
|
||||
17
app/test/fixtures/gemini-test-b-cert.pem
vendored
Normal file
17
app/test/fixtures/gemini-test-b-cert.pem
vendored
Normal 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
28
app/test/fixtures/gemini-test-b-key.pem
vendored
Normal 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
163
app/test/gemini_test.dart
Normal 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
109
app/test/gemtext_test.dart
Normal 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, 'שלום');
|
||||
});
|
||||
}
|
||||
33
app/test/key_store_test.dart
Normal file
33
app/test/key_store_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
129
app/test/spartan_test.dart
Normal 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
43
app/test/tofu_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue