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

View file

@ -220,4 +220,13 @@ void main() {
);
expect(storage.peopleOf(builtIn).path, '/support/people');
});
test("an account's wait is kept per instance, as its keys are (Halkyon "
"Learn's ADR 0021, D31)", () {
final storage = InstanceStorage('/support', builtIn: builtIn);
expect(storage.waitOf(builtIn), 'halkyon_account_wait');
expect(storage.waitOf(other), 'halkyon_account_wait@learn.example.org');
expect(storage.waitOf(other), isNot(storage.keysOf(other)));
});
}

View file

@ -465,4 +465,62 @@ void main() {
}),
);
});
group("where new accounts wait for the instance's operator (Halkyon "
"Learn's ADR 0021, D19, D31)", () {
/// A sign-up up to the code mailed, with the handle `anna`.
Future<AccountSetupCubit> upToTheCode() async {
final cubit = setUpAs(AccountSetupMode.signUp)
..addressEntered('anna@example.org');
await cubit.passwordEntered('correct horse', 'correct horse');
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
cubit.handleEntered('anna');
return cubit;
}
test('the new account waits: the device keeps its wait in place of a '
'session, sets up no authenticator, and the sheet shows no '
'problem, as the wait screen takes its place', () async {
auth.offeredSignUps = SignUps.approval;
final cubit = await upToTheCode();
final states = await statesOf(
cubit,
() => cubit.mailCodeEntered(gateway.codeFor('anna@example.org')),
);
final person = gateway.accounts['anna@example.org']!.person;
expect(auth.status, Waiting(fakeWaitOf(person, handle: 'anna')));
expect(states, [at(AccountSetupStep.mailCode, busy: true)]);
expect(gateway.calls, isNot(contains('enrol')));
expect(auth.usedSessions, isEmpty);
});
test('closed, the code proves the address first, then the sign-up is '
'refused, and nothing is made', () async {
auth.offeredSignUps = SignUps.closed;
final cubit = await upToTheCode();
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(
cubit.state,
at(AccountSetupStep.mailCode, problem: PasswordProblem.signUpsClosed),
);
expect(gateway.accounts, isEmpty);
expect(auth.status, const SignedOut());
});
test('closed, an account of before (an address, no password) still takes '
'its password', () async {
auth.offeredSignUps = SignUps.closed;
gateway.addAddressOnly('anna@example.org');
final cubit = await upToTheCode();
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(cubit.state, at(AccountSetupStep.authenticator));
});
});
}

View file

@ -107,6 +107,30 @@ void main() {
expect(addresses.addresses[anna.person], 'anna@example.org');
});
test("an account waiting for the instance's operator signs in to its "
"wait (Halkyon Learn's ADR 0021, D31): the device keeps it, with no "
"session, and nobody's keys", () async {
await keys.keep('ada', crypto.keysNumbered(1));
final wait = fakeWaitOf(
anna.person,
).standing(AccountWaitState.suspended);
gateway.accountWaits[anna.person] = wait;
final unlocked = await passwords.unlock(
'anna@example.org',
'correct horse',
);
await expectLater(
passwords.signIn(unlocked, totpCode: fakeTotpCode, language: 'en'),
throwsA(isA<AccountWaits>().having((w) => w.wait, 'wait', wait)),
);
await settle();
expect(auth.status, Waiting(wait));
expect(auth.usedSessions, isEmpty);
expect(keys.kept, isEmpty);
});
test('is refused alike for a wrong password or code, signing nobody in, '
'and begins a new challenge for the next try', () async {
final wrong = await passwords.unlock('anna@example.org', 'wrong');

View file

@ -969,4 +969,45 @@ void main() {
expect(anna.password, 'correct horse');
});
});
testWidgets("an account waiting for the instance's operator signs in to "
"its wait, which takes the sheet's place (Halkyon Learn's ADR 0021, "
'D31)', (tester) async {
final anna = dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
);
dependencies.passwordGateway.accountWaits[anna.person] = fakeWaitOf(
anna.person,
handle: 'anna',
).standing(AccountWaitState.suspended);
await start(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
await tester.pumpAndSettle();
await tester.enterText(field('Email address'), 'anna@example.org');
await tester.enterText(field('Password'), 'correct horse');
await tapButton(tester, 'Continue');
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tapButton(tester, 'Sign in');
expect(
find.text(
'The operator of test.example.org has suspended your account. Only '
'they can lift it.',
),
findsOneWidget,
);
expect(find.text('Code from your authenticator app'), findsNothing);
expect(
find.text("The address, the password or the code isn't right."),
findsNothing,
);
expect(gateway.usedSessions, isEmpty);
expect(dependencies.keys.kept, isEmpty);
});
}

View file

@ -67,6 +67,9 @@ final class _Instance() {
'halkyon_base.ProfileRefusedException',
PasswordRefusedException() =>
'halkyon_base.PasswordRefusedException',
AccountWaitingException() =>
'halkyon_base.AccountWaitingException',
SignUpRefusedException() => 'halkyon_base.SignUpRefusedException',
_ => throw ArgumentError.value(refusal),
},
'data': model.toJson(),
@ -594,4 +597,70 @@ void main() {
failsWith(SimpleLoginProblem.failed),
);
});
group("an account waiting for the instance's operator (Halkyon Learn's "
'ADR 0021, D19, D31)', () {
test('a sign-in that ends in a wait hands over its pass and words, '
'suspended as the instance says', () async {
instance.refusals['halkyon_base.simpleLogin/complete'] =
AccountWaitingException(
pass: 'anna-pass',
words: ['abandon', 'ability', 'able', 'about'],
handle: 'anna',
suspended: true,
);
final start = await gateway.begin(SimpleLoginReturn.scheme);
await expectLater(
gateway.complete(start.attempt, 'code'),
throwsA(
isA<AccountWaits>().having(
(waits) => waits.wait,
'wait',
const AccountWait(
pass: 'anna-pass',
words: ['abandon', 'ability', 'able', 'about'],
handle: 'anna',
state: AccountWaitState.suspended,
),
),
),
);
});
test("a sign-up the instance doesn't take says why", () async {
KeysSetup setup() => KeysSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
passwordProofKey: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
recoveryProofKey: ByteData(32),
);
for (final (reason, problem) in [
(SignUpRefusal.closed, SimpleLoginProblem.signUpsClosed),
(SignUpRefusal.tooManyWaiting, SimpleLoginProblem.tooManyWaiting),
]) {
instance.refusals['halkyon_base.simpleLogin/signUp'] =
SignUpRefusedException(reason: reason);
await expectLater(
gateway.signUp(_Instance.attempt, 'anna', setup()),
failsWith(problem),
);
}
});
});
}

View file

@ -249,6 +249,64 @@ void main() {
});
});
group("an account waiting for the instance's operator (Halkyon Learn's "
'ADR 0021, D31)', () {
test('a known account that waits signs in to its wait: the device keeps '
'it, with no session and no keys', () async {
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: (await crypto.newKeys(password)).setup,
);
final wait = fakeWaitOf(
'person-9',
handle: 'anna',
).standing(AccountWaitState.suspended);
gateway.accountWaits['person-9'] = wait;
await expectLater(
simpleLogin.signIn(),
throwsA(isA<AccountWaits>().having((w) => w.wait, 'wait', wait)),
);
expect(auth.status, Waiting(wait));
expect(auth.usedSessions, isEmpty);
expect(keys.kept, isEmpty);
});
test('where new accounts wait for approval, someone new signs up to a '
'wait, the keys made waiting with the account', () async {
auth.offeredSignUps = SignUps.approval;
final newcomer = (await simpleLogin.signIn())! as SimpleLoginNewcomer;
final made = await simpleLogin.makeKeys(password);
await expectLater(
simpleLogin.signUp(newcomer, handle: 'anna_mueller', made: made),
throwsA(isA<AccountWaits>()),
);
expect(
auth.status,
Waiting(fakeWaitOf('person-1', handle: 'anna_mueller')),
);
expect(gateway.accounts['person-1']!.keys, made.setup);
expect(keys.kept, isEmpty);
});
test('where the instance takes no new accounts, someone new is refused, '
'and nothing is made', () async {
auth.offeredSignUps = SignUps.closed;
final newcomer = (await simpleLogin.signIn())! as SimpleLoginNewcomer;
final made = await simpleLogin.makeKeys(password);
await expectLater(
simpleLogin.signUp(newcomer, handle: 'anna_mueller', made: made),
failsWith(SimpleLoginProblem.signUpsClosed),
);
expect(gateway.accounts, isEmpty);
expect(auth.status, const SignedOut());
});
});
group('an account with its keys', () {
late NewKeysMaterial material;
late SimpleLoginAccount account;

View file

@ -427,4 +427,71 @@ void main() {
}
});
}
group("where new accounts wait for the operator's approval (Halkyon "
"Learn's ADR 0021, D19)", () {
/// Someone new goes as far as creating the account.
Future<void> signUp(WidgetTester tester) async {
await tapButton(tester, 'Continue with SimpleLogin');
await tester.tap(find.widgetWithText(ListTile, 'Create an account'));
await tester.pumpAndSettle();
await tapButton(tester, 'Continue');
await tester.enterText(field('New password'), password);
await tester.enterText(field('New password again'), password);
await tapButton(tester, 'Continue');
await tapButton(tester, "I've written it down");
await typeCheckedWords(tester, fakePhrase);
await tapButton(tester, 'Create the account');
}
testWidgets('the start screen says so, and the new account waits: its '
"wait screen takes the sheet's place, with the handle and the "
'words to match', (tester) async {
gateway.offeredSignUps = SignUps.approval;
await start(tester);
expect(
find.text(
'New accounts on test.example.org wait until its operator '
'approves them.',
),
findsOneWidget,
);
await signUp(tester);
expect(find.text('@anna_mueller'), findsOneWidget);
expect(
find.text(
'Your account waits until the operator of test.example.org '
'approves it.',
),
findsOneWidget,
);
for (final word in fakeMatchingWords) {
expect(find.text(word), findsOneWidget);
}
expect(find.text('Create the account'), findsNothing);
expect(gateway.usedSessions, isEmpty);
expect(dependencies.keys.kept, isEmpty);
});
testWidgets('where the instance takes no new accounts, the start screen '
'says so, and someone new is refused', (tester) async {
gateway.offeredSignUps = SignUps.closed;
await start(tester);
expect(
find.text(
'test.example.org takes no new accounts: sign in with '
'yours.',
),
findsOneWidget,
);
await signUp(tester);
expect(find.text('This instance takes no new accounts.'), findsOneWidget);
expect(dependencies.simpleLoginGateway.accounts, isEmpty);
expect(gateway.status, const SignedOut());
});
});
}