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>
This commit is contained in:
parent
a060fbc21d
commit
7ac715cd97
2 changed files with 2284 additions and 103 deletions
|
|
@ -211,4 +211,45 @@ void main() {
|
|||
expect(find.text('App of ada'), findsOneWidget);
|
||||
expect(builds, 1, reason: "busy and its problem are the account tiles'");
|
||||
});
|
||||
|
||||
testWidgets("where the instance's operator takes no new accounts, nobody "
|
||||
"is offered to create one (Halkyon Learn's ADR 0021, D19)", (
|
||||
tester,
|
||||
) async {
|
||||
signedOut();
|
||||
gateway.offeredSignUps = SignUps.closed;
|
||||
await start(tester);
|
||||
|
||||
expect(
|
||||
find.text('test.example.org takes no new accounts: sign in with yours.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Create an account'), findsNothing);
|
||||
expect(find.text('Sign in'), findsOneWidget);
|
||||
expect(find.text('Continue with SimpleLogin'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('where new accounts wait for approval, the start screen says '
|
||||
'so, and offers all as before', (tester) async {
|
||||
signedOut();
|
||||
gateway.offeredSignUps = SignUps.approval;
|
||||
await start(tester);
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'New accounts on test.example.org wait until its operator approves '
|
||||
'them.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Create an account'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('open, the start screen says nothing of it', (tester) async {
|
||||
signedOut();
|
||||
await start(tester);
|
||||
|
||||
expect(find.textContaining('new accounts'), findsNothing);
|
||||
expect(find.textContaining('New accounts'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
234
packages/halkyon_app/test/auth/widget/wait_screen_test.dart
Normal file
234
packages/halkyon_app/test/auth/widget/wait_screen_test.dart
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue