halkyon/packages/halkyon_app/test/simplelogin/widget/simplelogin_sheet_test.dart

235 lines
7.6 KiB
Dart
Raw Normal View History

feat(app): signing in with SimpleLogin, its sheet and the start screen's offer (ADR 0020) Where the instance offers SimpleLogin, the start screen offers it first, saying that the instance keeps only the SimpleLogin ID, and opens a sheet that waits for SimpleLogin's page in the browser (Cancel ends the wait), then: for someone new, the choice between creating an account (the suggested handle or another, checked with the instance, the password twice, the recovery phrase shown once and three of its words typed back, nothing reaching the instance before) and linking theirs again with its handle and recovery phrase; for an account, its password opening its keys; for an account of before, the keys made with the handle it lacks. SimpleLoginCubit holds the steps, each refusal shown where it is mended (a handle's on the handle, a sign-in to begin again on the browser's), and closing the sheet stops waiting on the loopback. The suggested handle is never checked: the instance suggests none that anybody has, and every answer about a handle counts against its limit (D5). The fake's handle rule is now the module's. Learn and Todo build it with SimpleLogin's browser (halkyon_oidc_flutter, now a direct dependency of both, with their names on the loopback's page), their schemes and their loopback ports (43823, 43824), as the instance's forwarding page has them. Their tests offer no SimpleLogin; halkyon_app's test the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 01:21:29 +02:00
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's app as a test makes one: whose it is.
class const _PersonApp(final SignedIn person) extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
home: Scaffold(body: Text('App of ${person.authUserId}')),
);
}
const password = 'correct horse battery';
void main() {
late FakeAuthGateway gateway;
late TestAppDependencies dependencies;
setUp(() {
gateway = FakeAuthGateway(status: const SignedOut());
feat(app): signing in with SimpleLogin, its sheet and the start screen's offer (ADR 0020) Where the instance offers SimpleLogin, the start screen offers it first, saying that the instance keeps only the SimpleLogin ID, and opens a sheet that waits for SimpleLogin's page in the browser (Cancel ends the wait), then: for someone new, the choice between creating an account (the suggested handle or another, checked with the instance, the password twice, the recovery phrase shown once and three of its words typed back, nothing reaching the instance before) and linking theirs again with its handle and recovery phrase; for an account, its password opening its keys; for an account of before, the keys made with the handle it lacks. SimpleLoginCubit holds the steps, each refusal shown where it is mended (a handle's on the handle, a sign-in to begin again on the browser's), and closing the sheet stops waiting on the loopback. The suggested handle is never checked: the instance suggests none that anybody has, and every answer about a handle counts against its limit (D5). The fake's handle rule is now the module's. Learn and Todo build it with SimpleLogin's browser (halkyon_oidc_flutter, now a direct dependency of both, with their names on the loopback's page), their schemes and their loopback ports (43823, 43824), as the instance's forwarding page has them. Their tests offer no SimpleLogin; halkyon_app's test the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 01:21:29 +02:00
dependencies = TestAppDependencies(gateway: gateway);
dependencies.simpleLoginGateway.isOffered = true;
});
/// Starts signed out, on a phone tall enough for every sheet.
Future<void> start(WidgetTester tester) async {
tester.view.physicalSize = const Size(412, 1800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
AppFrameScope(
frame: testFrame(),
child: AppScope(
dependencies: dependencies,
child: SessionGate(
look: (context) => (themeMode: ThemeMode.light, locale: null),
app: _PersonApp.new,
),
),
),
);
await tester.pumpAndSettle();
}
Finder field(String label) => find.widgetWithText(TextField, label);
Future<void> tapButton(WidgetTester tester, String label) async {
final button = find.widgetWithText(FilledButton, label).last;
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pumpAndSettle();
}
/// Types the recovery phrase's words the check asks for.
Future<void> typeCheckedWords(WidgetTester tester, String phrase) async {
final words = wordsOf(phrase);
for (final element in find.byType(TextField).evaluate()) {
if ((element.widget as TextField).decoration?.labelText case final label?
when label.startsWith('Word ')) {
await tester.enterText(
field(label),
words[int.parse(label.substring('Word '.length)) - 1],
);
}
}
}
testWidgets('the start screen offers SimpleLogin first, saying what it '
'learns and what the instance keeps', (tester) async {
await start(tester);
expect(
find.widgetWithText(FilledButton, 'Continue with SimpleLogin'),
findsOneWidget,
);
expect(
find.textContaining('keeps only your SimpleLogin ID'),
findsOneWidget,
);
expect(find.textContaining('test.example.org'), findsWidgets);
});
testWidgets('offers no SimpleLogin where the instance has none', (
tester,
) async {
dependencies.simpleLoginGateway.isOffered = false;
await start(tester);
expect(find.text('Continue with SimpleLogin'), findsNothing);
});
testWidgets('someone new creates an account: the handle suggested, the '
'password twice, the phrase written down, then signed in', (
tester,
) async {
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.text('New here?'), findsOneWidget);
await tester.tap(find.widgetWithText(ListTile, 'Create an account'));
await tester.pumpAndSettle();
expect(find.text('anna_mueller'), findsOneWidget);
expect(find.text('@anna_mueller is free'), findsOneWidget);
await tester.enterText(field('Handle'), 'anna_m');
await tester.pump();
expect(find.text('@anna_mueller is free'), findsNothing);
await tester.enterText(field('Handle'), 'anna_mueller');
await tapButton(tester, 'Continue');
await tester.enterText(field('New password'), password);
await tester.enterText(field('New password again'), password);
await tapButton(tester, 'Continue');
expect(find.text('Your recovery phrase'), findsOneWidget);
await tapButton(tester, "I've written it down");
await typeCheckedWords(tester, fakePhrase);
await tapButton(tester, 'Create the account');
expect(find.text('App of person-1'), findsOneWidget);
});
testWidgets('an account opens its keys with its password, a wrong one '
'refused', (tester) async {
final material = await dependencies.crypto.newKeys(password);
dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.textContaining('as @anna'), findsOneWidget);
await tester.enterText(field('Password'), 'wrong horse battery');
await tapButton(tester, 'Open');
expect(find.text("The password isn't right."), findsOneWidget);
await tester.enterText(field('Password'), password);
await tapButton(tester, 'Open');
expect(find.text('App of person-9'), findsOneWidget);
});
testWidgets('someone whose SimpleLogin account was lost links theirs '
'again with its handle and recovery phrase', (tester) async {
final material = await dependencies.crypto.newKeys(password);
dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-lost',
handle: 'anna',
keys: material.setup,
);
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
await tester.tap(find.widgetWithText(ListTile, 'I already have one'));
await tester.pumpAndSettle();
expect(find.text('Link your account'), findsOneWidget);
expect(find.text('anna_mueller'), findsNothing);
await tester.enterText(field('Handle'), 'anna');
await tester.enterText(find.byType(TextField).last, otherFakePhrase);
await tapButton(tester, 'Link');
expect(
find.text("The handle or the recovery phrase isn't right."),
findsOneWidget,
);
await tester.enterText(find.byType(TextField).last, fakePhrase);
await tapButton(tester, 'Link');
expect(find.text('App of person-9'), findsOneWidget);
});
testWidgets('closing the browser leaves the start screen as it was', (
tester,
) async {
dependencies.browser.comesBack = () => null;
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.text('New here?'), findsNothing);
expect(
find.widgetWithText(FilledButton, 'Continue with SimpleLogin'),
findsOneWidget,
);
});
testWidgets("a refusal on SimpleLogin's page shows, and it begins again", (
tester,
) async {
dependencies.browser.comesBack = () => const OidcDenied('access_denied');
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.textContaining('nothing was shared'), findsOneWidget);
dependencies.browser.comesBack = null;
await tapButton(tester, 'Try again');
expect(find.text('New here?'), findsOneWidget);
});
for (final locale in HalkyonAppLocalizations.supportedLocales) {
testWidgets('every refusal has its words in $locale', (tester) async {
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
locale: locale,
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
home: Builder(
builder: (built) {
context = built;
return const SizedBox();
},
),
),
);
await tester.pumpAndSettle();
for (final problem in SimpleLoginProblem.values) {
expect(
simpleLoginFailureText(context, SimpleLoginFailed(problem)),
isNotEmpty,
reason: '$problem',
);
}
});
}
}