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 tapRun(WidgetTester tester, String label) async { final paragraph = tester.renderObject( 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 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(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 = []; 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); }); }