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