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:
Tizian 「ティツィアーン」 2026-10-25 15:29:22 +01:00
commit 7ac715cd97
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 2284 additions and 103 deletions

View file

@ -176,4 +176,40 @@ void main() {
);
expect(gateway.status, stored);
});
group("an account waiting for the instance's operator (Halkyon Learn's "
'ADR 0021, D31)', () {
final wait = fakeWaitOf('anna', handle: 'anna');
test('a wait kept from before shows as the app starts', () async {
start(status: Waiting(wait));
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(SessionStateWaiting(wait)));
});
test('follows the wait as it changes, and as it ends', () async {
start();
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
final states = <SessionState>[];
final listening = bloc.stream.listen(states.add);
await gateway.wait(wait);
await pumpEventQueue();
gateway.waitAnswer = AccountWaitState.approved;
await gateway.lookWhileWaiting();
await pumpEventQueue();
await gateway.stopWaiting();
await pumpEventQueue();
await listening.cancel();
expect(states, [
SessionStateWaiting(wait),
SessionStateWaiting(wait.standing(AccountWaitState.approved)),
const SessionStateSignedOut(),
]);
});
});
}

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -32,15 +33,60 @@ final class _Instance() {
/// Each call, by its endpoint and method, with the session it carried.
final calls = <({String call, String? session})>[];
/// What each call sent, by its endpoint and method.
final bodies = <String, String>{};
/// Who may sign up, as the basics say.
SignUpMode signUp = SignUpMode.open;
/// Where the wait stands for the pass `ben-pass`, as `waiting.look` says.
WaitingState waiting = WaitingState.waiting;
/// Whether a sign-up waits for the operator: a pass in place of the
/// session (Halkyon Learn's ADR 0021, D31).
bool signUpWaits = false;
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
calls.add((call: call, session: request.headers['authorization']));
bodies[call] = request.body;
String sent(Object? body) => jsonEncode(body);
return switch (call) {
'halkyon_base.passwordSignIn/signUp' when signUpWaits => http.Response(
sent({
// As the instance names it: by its module.
'className': 'halkyon_base.AccountWaitingException',
'data': AccountWaitingException(
pass: 'ben-pass',
words: ['abandon', 'ability', 'able', 'about'],
handle: 'ben',
suspended: false,
).toJson(),
}),
400,
),
'halkyon_base.passwordSignIn/signUp' => http.Response(
sent(_session(ben, 'ben-token', identified: true)),
200,
),
'halkyon_base.instance/basics' => http.Response(
sent(
InstanceBasics(
app: 'learn',
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/halkyon',
minimumAppBuild: 1,
signUp: signUp,
).toJson(),
),
200,
),
'halkyon_base.waiting/look' => http.Response(sent(waiting.toJson()), 200),
'halkyon_base.waiting/deleteAccount' => http.Response(
sent(waiting == WaitingState.waiting),
200,
),
'halkyon_base.waiting/forget' => http.Response('null', 200),
_ => http.Response('', 404),
};
});
@ -57,9 +103,26 @@ final class _Instance() {
).toJson();
}
/// A setup for a sign-up as a test sends it: none of it is read.
PasswordSetup _setup() => PasswordSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
verifier: ByteData(384),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
recoveryProofKey: ByteData(32),
);
void main() {
late _Instance instance;
late BaseClient app;
late MemoryAccountWaitStore waits;
late ServerpodAuthGateway gateway;
setUp(() async {
@ -73,7 +136,8 @@ void main() {
storage: _MemoryStorage(),
);
await app.auth.initialize();
gateway = ServerpodAuthGateway(app, base: app.modules);
waits = MemoryAccountWaitStore();
gateway = ServerpodAuthGateway(app, base: app.modules, waits: waits);
addTearDown(() async {
await gateway.dispose();
app.close();
@ -119,26 +183,7 @@ void main() {
);
final grant = await passwords.signUp(
PasswordSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
verifier: ByteData(384),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
recoveryProofKey: ByteData(32),
),
_setup(),
request: '0191f0c4-3333-7000-8000-000000000003',
code: 'K7QXM2PD',
handle: 'ben',
@ -160,4 +205,146 @@ void main() {
isEmpty,
);
});
group("waiting for the operator (Halkyon Learn's ADR 0021, D31)", () {
const ben = AccountWait(
pass: 'ben-pass',
words: ['abandon', 'ability', 'able', 'about'],
handle: 'ben',
);
test('a sign-up the instance lets wait hands the device a pass in '
'place of a session', () async {
instance.signUpWaits = true;
final passwords = ServerpodPasswordGateway(
app,
base: app.modules,
httpClient: instance.client,
);
await expectLater(
passwords.signUp(
_setup(),
request: '0191f0c4-3333-7000-8000-000000000003',
code: 'K7QXM2PD',
handle: 'ben',
),
throwsA(isA<AccountWaits>().having((w) => w.wait, 'wait', ben)),
);
expect(
gateway.status,
const SignedOut(),
reason:
'kept only by the '
'repository, which hands it to the gateway',
);
});
test('keeps the wait across starts, on the device alone, and tells it '
'as the status until a session takes its place', () async {
final changes = <AuthStatus>[];
gateway.statusChanges.listen(changes.add);
await gateway.wait(ben);
expect(gateway.status, const Waiting(ben));
expect(waits.kept, ben);
final again = ServerpodAuthGateway(app, base: app.modules, waits: waits);
addTearDown(again.dispose);
expect(again.status, const SignedOut(), reason: 'not yet read');
await again.initialize();
expect(again.status, const Waiting(ben));
await gateway.useSession(
SessionGrant(
'${_Instance.ben}',
AuthSuccess.fromJson(
_Instance._session(_Instance.ben, 'ben-token', identified: true),
),
),
);
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.ben}', isIdentified: true),
);
expect(waits.kept, isNull);
expect(changes.first, const Waiting(ben));
});
test('asks where the wait stands with the pass alone, and keeps what '
'the instance says', () async {
await gateway.wait(ben);
expect(await gateway.lookWhileWaiting(), AccountWaitState.waiting);
instance.waiting = WaitingState.suspended;
expect(await gateway.lookWhileWaiting(), AccountWaitState.suspended);
expect(gateway.status, Waiting(ben.standing(AccountWaitState.suspended)));
instance.waiting = WaitingState.approved;
expect(await gateway.lookWhileWaiting(), AccountWaitState.approved);
expect(
waits.kept,
ben.standing(AccountWaitState.approved),
reason:
'the instance forgot the pass with that answer: the device '
'remembers it was approved',
);
final look = instance.calls.lastWhere(
(call) => call.call == 'halkyon_base.waiting/look',
);
expect(look.session, isNull);
expect(jsonDecode(instance.bodies[look.call]!), {'pass': 'ben-pass'});
});
test('a pass the instance knows no more ends the wait', () async {
await gateway.wait(ben);
instance.waiting = WaitingState.unknown;
expect(await gateway.lookWhileWaiting(), AccountWaitState.ended);
expect(gateway.status, Waiting(ben.standing(AccountWaitState.ended)));
});
test('deletes a new account with its pass and stops waiting; a '
'suspended one stays', () async {
await gateway.wait(ben);
instance.waiting = WaitingState.suspended;
expect(await gateway.deleteWaitingAccount(), isFalse);
expect(gateway.status, const Waiting(ben));
instance.waiting = WaitingState.waiting;
expect(await gateway.deleteWaitingAccount(), isTrue);
expect(gateway.status, const SignedOut());
expect(waits.kept, isNull);
});
test('stops waiting: the pass forgotten on the instance and here, but '
'not asked of an instance that forgot it already', () async {
await gateway.wait(ben);
await gateway.stopWaiting();
expect(gateway.status, const SignedOut());
expect(waits.kept, isNull);
expect(
instance.calls.where((c) => c.call == 'halkyon_base.waiting/forget'),
hasLength(1),
);
await gateway.wait(ben.standing(AccountWaitState.approved));
await gateway.stopWaiting();
expect(
instance.calls.where((c) => c.call == 'halkyon_base.waiting/forget'),
hasLength(1),
);
});
test('says who may sign up, as the basics do', () async {
expect(await gateway.signUps(), SignUps.open);
instance.signUp = SignUpMode.approval;
expect(await gateway.signUps(), SignUps.approval);
instance.signUp = SignUpMode.closed;
expect(await gateway.signUps(), SignUps.closed);
});
});
}

View file

@ -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);
});
}

View 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);
});
}