2026-10-11 22:07:25 +03:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
import 'package:actors/actors.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
|
|
|
|
import 'package:mews/application/actors/fetch_actor.dart';
|
2026-10-11 22:07:25 +03:00
|
|
|
import 'package:mews/mews/document.dart';
|
|
|
|
|
|
|
|
|
|
import 'fake_protocol_server.dart';
|
2026-10-11 11:05:03 +03:00
|
|
|
|
|
|
|
|
const page = '''
|
|
|
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
|
|
|
<head><title>Parsed</title><meta name="mews-profile" content="0.1" /></head>
|
|
|
|
|
<body><p>Some content.</p></body>
|
|
|
|
|
</html>
|
|
|
|
|
''';
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
const gemtext = '# Title\n\nSome text.\n';
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
void main() {
|
|
|
|
|
test('ParseSource parses without touching the network', () async {
|
|
|
|
|
final actor = Actor.create(FetchHandler.new);
|
|
|
|
|
addTearDown(actor.close);
|
|
|
|
|
|
|
|
|
|
final result = await actor
|
|
|
|
|
.send(const ParseSource(url: 'https://example.com/parsed.html', source: page));
|
|
|
|
|
|
|
|
|
|
expect(result, isA<FetchPageOk>());
|
|
|
|
|
final ok = result as FetchPageOk;
|
|
|
|
|
expect(ok.page.title, 'Parsed');
|
|
|
|
|
expect(ok.page.isMews, isTrue);
|
|
|
|
|
expect(ok.notModified, isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-12 11:13:34 +03:00
|
|
|
test('ParseSource offers a web page as a simplified page', () async {
|
2026-10-11 11:05:03 +03:00
|
|
|
final actor = Actor.create(FetchHandler.new);
|
|
|
|
|
addTearDown(actor.close);
|
|
|
|
|
|
2026-10-12 11:13:34 +03:00
|
|
|
final result = await actor.send(const ParseSource(
|
|
|
|
|
url: 'https://example.com/broken.html',
|
|
|
|
|
source: '<h1>Loose</h1><p>Never closed'));
|
2026-10-11 11:05:03 +03:00
|
|
|
|
|
|
|
|
expect(result, isA<FetchParseError>());
|
2026-10-12 11:13:34 +03:00
|
|
|
final error = result as FetchParseError;
|
|
|
|
|
// No conformance marker, so this is an ordinary web page rather than a
|
|
|
|
|
// Mews page with a markup error.
|
|
|
|
|
expect(error.declaresMews, isFalse);
|
|
|
|
|
expect(error.simplified, isNotNull);
|
|
|
|
|
expect(error.simplified!.body.whereType<HeadingBlock>(), isNotEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('ParseSource reports a markup error on a page claiming Mews', () async {
|
|
|
|
|
final actor = Actor.create(FetchHandler.new);
|
|
|
|
|
addTearDown(actor.close);
|
|
|
|
|
|
|
|
|
|
final result = await actor.send(const ParseSource(
|
|
|
|
|
url: 'https://example.com/broken.html',
|
|
|
|
|
source: '<html><head><title>T</title>'
|
|
|
|
|
'<meta name="mews-profile" content="0.1"></head>'
|
|
|
|
|
'<body><p>Never closed</body></html>'));
|
|
|
|
|
|
|
|
|
|
expect(result, isA<FetchParseError>());
|
|
|
|
|
expect((result as FetchParseError).declaresMews, isTrue);
|
2026-10-11 11:05:03 +03:00
|
|
|
});
|
2026-10-11 22:07:25 +03:00
|
|
|
|
|
|
|
|
// 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>());
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-11 11:05:03 +03:00
|
|
|
}
|