halkyon/packages/halkyon_app/test/auth/widget/account_tiles_test.dart
Tizian 「ティツィアーン」 5b9df73756
feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)
Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 16:14:37 +02:00

389 lines
13 KiB
Dart

import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's app as a test makes one: whose it is, and the
/// account's tiles in the app's own words.
class const _PersonApp(final SignedIn person) extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
home: Scaffold(
body: ListView(
children: [
Text('App of ${person.authUserId}'),
const AccountTiles(
deleteMessage: 'Your notes are deleted everywhere.',
),
],
),
),
);
}
void main() {
late FakeAuthGateway gateway;
late TestAppDependencies dependencies;
Future<void> start(WidgetTester tester, {InstanceChoice? choice}) async {
dependencies = TestAppDependencies(gateway: gateway);
final gate = AppScope(
dependencies: dependencies,
child: SessionGate(
look: (context) => (themeMode: ThemeMode.light, locale: null),
app: _PersonApp.new,
),
);
await tester.pumpWidget(
AppFrameScope(
frame: testFrame(),
child: switch (choice) {
final choice? => InstanceScope(
notifier: choice,
checker: FakeInstanceChecker(),
countryNames: MemoryCountryNames(const {}),
subdivisionNames: const MemorySubdivisionNames({}),
child: gate,
),
null => gate,
},
),
);
await tester.pumpAndSettle();
}
setUp(() => gateway = FakeAuthGateway());
testWidgets('says on which instance the account is signed in', (
tester,
) async {
await start(tester);
expect(find.text('Signed in on test.example.org'), findsOneWidget);
expect(
find.text('Switch account'),
findsNothing,
reason: 'no instances to switch among here',
);
expect(find.text('Sign out'), findsOneWidget);
expect(find.text('Delete account'), findsOneWidget);
});
testWidgets('switching account lists the accounts on the device, the '
'instance in use named', (tester) async {
final instance = Instance.parse('learn.example.org')!;
final choice = InstanceChoice(
MemoryInstanceStore((current: instance, known: [instance])),
builtIn: instance,
forget: (instance) async {},
sharesStorage: (a, b) => false,
);
await choice.load(usedBefore: false);
await start(tester, choice: choice);
expect(find.text('Switch account'), findsOneWidget);
expect(find.text('learn.example.org'), findsWidgets);
await tester.tap(find.text('Switch account'));
await tester.pumpAndSettle();
expect(find.text('Accounts'), findsOneWidget);
});
testWidgets('signing out asks nothing, deleting nothing', (tester) async {
await start(tester);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(find.text('Sign out?'), findsNothing);
expect(gateway.status, const SignedOut());
expect(gateway.accountDeletions, 0);
});
testWidgets("deleting the account asks first, in the app's words; Cancel "
'keeps it', (tester) async {
await start(tester);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
expect(find.text('Delete your account?'), findsOneWidget);
expect(find.text('Your notes are deleted everywhere.'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 0);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('deleting an identified account while the instance is out of '
'reach says so, and deletes nothing (the short reading)', (tester) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
await start(tester);
dependencies.passwordGateway.failing = const PasswordFailed(
PasswordProblem.failed,
);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(
find.text(
"Couldn't reach the instance. Check the connection and try again.",
),
findsOneWidget,
);
expect(gateway.accountDeletions, 0);
expect(find.text('App of github-1'), findsOneWidget);
});
testWidgets('deleting an account whose setup never finished asks for the '
'password and a code by mail', (tester) async {
// The first person the fake password gateway makes.
const person = '00000000-0000-4000-8000-000000000001';
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: person, isIdentified: true),
);
await start(tester);
dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
finished: false,
);
dependencies.addresses.addresses[person] = 'anna@example.org';
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(find.textContaining('a code by mail confirms it'), findsOneWidget);
await tester.enterText(field('Current password'), 'correct horse');
await tester.tap(find.text('Email me a code'));
await tester.pumpAndSettle();
expect(
dependencies.passwordGateway.mailed.single.purpose,
MailCodePurpose.deletion,
);
expect(find.textContaining('anna@example.org'), findsOneWidget);
expect(gateway.accountDeletions, 0);
await tester.enterText(
field('Code from the email'),
dependencies.passwordGateway.codeFor('anna@example.org'),
);
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(
dependencies.passwordGateway.accounts,
isNot(contains('anna@example.org')),
);
});
testWidgets("deleting an account whose authenticators' reset took effect "
'asks for a code by mail, as the instance takes it, and for the '
"address where this device doesn't know it", (tester) async {
// The first person the fake password gateway makes.
const person = '00000000-0000-4000-8000-000000000001';
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: person, isIdentified: true),
);
await start(tester);
// Its authenticator still confirmed, the reset taken effect but not
// carried out (the third reading's fourth finding).
dependencies.passwordGateway
.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
)
.authenticatorReset = clock.now().subtract(
const Duration(hours: 1),
);
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(find.textContaining('a code by mail confirms it'), findsOneWidget);
await tester.enterText(field('Current password'), 'correct horse');
await tester.enterText(field('Email address'), 'anna@example.org');
await tester.tap(find.text('Email me a code'));
await tester.pumpAndSettle();
expect(
dependencies.passwordGateway.mailed.single.purpose,
MailCodePurpose.deletion,
);
await tester.enterText(
field('Code from the email'),
dependencies.passwordGateway.codeFor('anna@example.org'),
);
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
});
testWidgets('deleting an account with a password asks for it and a '
'code, which a wrong one fails, deleting nothing', (tester) async {
// The first person the fake password gateway makes.
const person = '00000000-0000-4000-8000-000000000001';
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: person, isIdentified: true),
);
await start(tester);
dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
);
// The address this device signed in with, which deleting sends.
dependencies.addresses.addresses[person] = 'anna@example.org';
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 0);
expect(field('Current password'), findsOneWidget);
await tester.enterText(field('Current password'), 'wrong horse');
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(
find.text("The address, the password or the code isn't right."),
findsOneWidget,
);
expect(gateway.accountDeletions, 0);
expect(dependencies.passwordGateway.accounts, contains('anna@example.org'));
await tester.enterText(field('Current password'), 'correct horse');
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(
dependencies.passwordGateway.accounts,
isNot(contains('anna@example.org')),
);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('while the account is deleted, the person stays in their app, '
'Delete account showing it is under way', (tester) async {
await start(tester);
gateway.deletionWaits = Completer();
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
// The spinner never settles while it runs.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
find.text('App of person-1'),
findsOneWidget,
reason: 'never the launch screen in between',
);
expect(
find.descendant(
of: find.widgetWithText(ListTile, 'Delete account'),
matching: find.byType(ButtonLoadingIndicator),
),
findsOneWidget,
);
gateway.deletionWaits!.complete();
await tester.pumpAndSettle();
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('a deletion the instance never got keeps the person in their '
'app, and says so once', (tester) async {
await start(tester);
gateway.deletionError = Exception('No connection');
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(
find.text(
"Couldn't delete your account: the instance wasn't reached. "
'Try again.',
),
findsOneWidget,
);
expect(find.text('App of person-1'), findsOneWidget);
expect(gateway.status, isA<SignedIn>());
expect(find.byType(ButtonLoadingIndicator), findsNothing);
});
testWidgets("a sign-out the device's storage failed keeps the person in "
'their app, and says so', (tester) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
)..signOutError = Exception('The keyring is locked');
await start(tester);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(
find.text("Couldn't sign out on this device. Try again."),
findsOneWidget,
);
expect(find.text('App of github-1'), findsOneWidget);
});
testWidgets('speaks German', (tester) async {
await start(tester);
final german = lookupHalkyonAppLocalizations(const Locale('de'));
expect(german.accountSignedInOn('x'), 'Angemeldet auf x');
expect(german.accountSwitch, 'Konto wechseln');
expect(german.accountSignOut, 'Abmelden');
expect(german.accountDelete, 'Konto löschen');
expect(german.accountDeleteTitle, 'Dein Konto löschen?');
expect(
german.accountSignOutFailed,
'Abmelden auf diesem Gerät hat nicht geklappt. Versuch es noch einmal.',
);
expect(
german.accountDeleteFailed,
'Dein Konto wurde nicht gelöscht: Die Instanz war nicht erreichbar. '
'Versuch es noch einmal.',
);
});
}