halkyon/packages/halkyon_app/test/auth/widget/wait_screen_test.dart
Tizian 「ティツィアーン」 7ac715cd97
feat(app): a wait screen for accounts that wait for the operator (ADR 0021, step 1)
An account signed in or up to on an instance whose operator approves
new accounts, or one suspended, gets no session but a wait: the device
keeps its pass, words and handle in the keyring per instance
(SecureAccountWaitStore, InstanceStorage.waitOf), the auth status is
Waiting, and SessionGate shows WaitScreen in its own app, so the start
screen's sheets go with it. Every sign-in flow hands over to it: the
password's sign-in, sign-up, reset and account reset, SimpleLogin's
sign-in, sign-up and mending; their cubits show no problem then.

The wait screen shows the handle, the four words the operator reads out
for the person to match, and that Halkyon never asks anyone to read them
out (D33); it asks where the wait stands as it opens, every 30 seconds,
as the app comes back and when asked. A new account can be deleted with
its pass, a suspended one can't (ADR 0017). Approved, it leads to the
usual sign-in, once more as before (D31); a pass the instance knows no
more ends the wait. The start screen says where new accounts wait for
approval, and offers no Create an account where the operator takes none;
both refusals (closed, too many waiting) have their words, en and de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:17:05 +01:00

234 lines
7.3 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.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}')),
);
}
/// The wait screen (Halkyon Learn's ADR 0021, D31, D33), as the session gate
/// shows it while an account signed in or up to here waits for the
/// instance's operator.
void main() {
late FakeAuthGateway gateway;
late TestAppDependencies dependencies;
final anna = fakeWaitOf('anna', handle: 'anna');
setUp(() {
gateway = FakeAuthGateway(status: Waiting(anna));
dependencies = TestAppDependencies(gateway: gateway);
});
Future<void> start(WidgetTester tester) async {
tester.view.physicalSize = const Size(412, 1200);
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();
}
Future<void> tap(WidgetTester tester, String label) async {
final button = find.text(label);
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pumpAndSettle();
}
const waiting =
'Your account waits until the operator of test.example.org approves it.';
testWidgets('shows the handle, the words to match and what to do with '
'them: only check them, never read them out (D33)', (tester) async {
await start(tester);
expect(find.text('Halkyon Test'), findsOneWidget);
expect(find.text('@anna'), findsOneWidget);
expect(find.text(waiting), findsOneWidget);
expect(find.text('Matching code'), findsOneWidget);
for (final word in fakeMatchingWords) {
expect(find.text(word), findsOneWidget);
}
expect(
find.text(
'When the operator of test.example.org reads you four words, check '
'that they are these. Never read them out yourself: Halkyon never '
'asks anyone to.',
),
findsOneWidget,
);
expect(find.text(anna.pass), findsNothing, reason: 'the pass is a secret');
expect(find.text('Start on test.example.org:'), findsNothing);
});
testWidgets('asks the instance as it opens, every 30 seconds, as the app '
'comes back, and when asked', (tester) async {
await start(tester);
expect(gateway.looks, 1);
await tester.pump(const Duration(seconds: 30));
expect(gateway.looks, 2);
// Away and back, as the system moves an app.
[
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
AppLifecycleState.hidden,
AppLifecycleState.inactive,
AppLifecycleState.resumed,
].forEach(tester.binding.handleAppLifecycleStateChanged);
await tester.pump();
expect(gateway.looks, 3);
await tap(tester, 'Check now');
expect(gateway.looks, 4);
});
testWidgets('approved, it says so and leads to the usual sign-in, once '
'more as before (D31): the pass holds no session', (tester) async {
await start(tester);
gateway.waitAnswer = AccountWaitState.approved;
await tester.pump(const Duration(seconds: 30));
await tester.pumpAndSettle();
expect(
find.text(
'The operator of test.example.org has approved your account. Sign '
'in once more, as you did before.',
),
findsOneWidget,
);
expect(find.text('Matching code'), findsNothing);
expect(gateway.usedSessions, isEmpty);
// Nothing left to ask.
await tester.pump(const Duration(seconds: 60));
expect(gateway.looks, 2);
await tap(tester, 'Sign in');
expect(find.text('Start on test.example.org:'), findsOneWidget);
expect(gateway.waitsStopped, 1);
});
testWidgets('a wait the instance knows no more ends: refused, or too old', (
tester,
) async {
await start(tester);
gateway.waitAnswer = AccountWaitState.ended;
await tap(tester, 'Check now');
expect(
find.text(
'This sign-up has ended: the operator of test.example.org refused '
'it, or it waited too long.',
),
findsOneWidget,
);
await tap(tester, 'Back to the start');
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('a new account is deleted with its pass, after asking', (
tester,
) async {
await start(tester);
await tap(tester, 'Delete this account');
expect(find.text('Delete this account?'), findsOneWidget);
await tap(tester, 'Cancel');
expect(gateway.waitingDeletions, 0);
expect(find.text(waiting), findsOneWidget);
await tap(tester, 'Delete this account');
await tester.tap(find.widgetWithText(FilledButton, 'Delete this account'));
await tester.pumpAndSettle();
expect(gateway.waitingDeletions, 1);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('a deletion that fails says so, and the wait stays', (
tester,
) async {
gateway.waitingDeletionError = Exception('No connection');
await start(tester);
await tap(tester, 'Delete this account');
await tester.tap(find.widgetWithText(FilledButton, 'Delete this account'));
await tester.pumpAndSettle();
expect(
find.text("Couldn't delete the account. Try again."),
findsOneWidget,
);
expect(find.text(waiting), findsOneWidget);
});
testWidgets('a suspended account shows its words but offers no deletion: '
"that takes its owner's password and a code (ADR 0017)", (tester) async {
gateway = FakeAuthGateway(
status: Waiting(anna.standing(AccountWaitState.suspended)),
);
dependencies = TestAppDependencies(gateway: gateway);
await start(tester);
expect(
find.text(
'The operator of test.example.org has suspended your account. Only '
'they can lift it.',
),
findsOneWidget,
);
expect(find.text('abandon'), findsOneWidget);
expect(find.text('Delete this account'), findsNothing);
expect(find.text('Sign out'), findsOneWidget);
});
testWidgets('an instance out of reach is said so, and asked again', (
tester,
) async {
gateway.lookError = Exception('No connection');
await start(tester);
expect(
find.text(
"test.example.org couldn't be reached. It's asked again every 30 "
'seconds.',
),
findsOneWidget,
);
expect(find.text(waiting), findsOneWidget);
gateway.lookError = null;
await tester.pump(const Duration(seconds: 30));
await tester.pumpAndSettle();
expect(find.textContaining("couldn't be reached"), findsNothing);
});
testWidgets('signing out stops waiting on this device: the start screen '
'follows, where signing in again waits anew', (tester) async {
await start(tester);
await tap(tester, 'Sign out');
expect(gateway.waitsStopped, 1);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
}