test(app): the password cubits' states, every refusal included

Signing in (a code, a recovery code, an account never finished, a lost
authenticator by mail and by phrase, an impostor instance, no
connection), setting up (signing up, a password for someone signed in,
finishing an account's reset with new keys or the old), getting back in
(with the phrase, starting a reset without it) and the account section
(what keeps the account, its steps, adding an authenticator, a new
phrase), each as the states it emits.

They found that reading what keeps the account wrapped two refusals at
once in a ParallelWaitError, which no caller takes for one: offline, the
account section showed nothing. The first refusal now goes up as it is.

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-20 20:12:31 +02:00
commit b51445a01a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1238 additions and 1 deletions

View file

@ -0,0 +1,214 @@
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 'states.dart';
/// A state of getting back in at [step], [busy] or not, with [problem] or
/// none.
Matcher at(
AccountRecoveryStep step, {
bool busy = false,
PasswordProblem? problem,
}) => isA<AccountRecoveryState>()
.having((state) => state.step, 'step', step)
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem?.problem, 'problem', problem);
void main() {
late FakeAuthGateway auth;
late FakePasswordGateway gateway;
late FakeAccountCrypto crypto;
late PasswordRepository passwords;
late AccountRecoveryCubit cubit;
late FakePasswordAccount anna;
late DateTime now;
setUp(() {
now = DateTime.utc(2026, 10, 20, 12);
auth = FakeAuthGateway();
gateway = FakePasswordGateway(auth);
crypto = FakeAccountCrypto();
passwords = PasswordRepository(
gateway,
crypto,
MemoryAccountKeyStore(),
auth,
MemorySignInAddresses(),
);
cubit = AccountRecoveryCubit(passwords, language: 'en');
anna = gateway.addAccount(
'anna@example.org',
password: 'forgotten',
crypto: crypto,
);
});
tearDown(() async {
await cubit.close();
await passwords.dispose();
});
Future<void> withPhrase({
String phrase = fakePhraseForTests,
String password = 'new horse',
String? again,
String code = fakeTotpCode,
}) => cubit.phraseEntered(
address: ' anna@example.org',
phrase: phrase,
password: password,
again: again ?? password,
code: code,
);
group('with the recovery phrase', () {
test('a new password and a code sign in, the keys kept', () async {
final keysBefore = anna.passwordKeys;
expect(
await statesNow(
cubit,
() => cubit.chosen(AccountRecoveryStep.withPhrase),
),
[at(AccountRecoveryStep.withPhrase)],
);
expect(
await statesOf(cubit, withPhrase),
[
isA<AccountRecoveryState>().having(
(state) => state.address,
'address',
'anna@example.org',
),
at(AccountRecoveryStep.withPhrase, busy: true),
at(AccountRecoveryStep.done, busy: true),
at(AccountRecoveryStep.done),
],
);
expect(anna.password, 'new horse');
expect(anna.passwordKeys, isNot(same(keysBefore)));
expect(
auth.status,
SignedIn(authUserId: anna.person, isIdentified: true),
);
});
test('is refused for passwords that differ, a wrong code, a phrase '
"that is none or not the account's, nothing changing", () async {
cubit.chosen(AccountRecoveryStep.withPhrase);
await withPhrase(again: 'new hrose');
expect(cubit.state.passwordsDiffer, isTrue);
expect(gateway.calls, isEmpty);
expect(await statesOf(cubit, () => withPhrase(code: '000000')), [
isA<AccountRecoveryState>().having(
(state) => state.passwordsDiffer,
'passwordsDiffer',
isFalse,
),
at(AccountRecoveryStep.withPhrase, busy: true),
at(
AccountRecoveryStep.withPhrase,
problem: PasswordProblem.wrongCredentials,
),
]);
await withPhrase(phrase: otherFakePhrase);
expect(
cubit.state,
at(
AccountRecoveryStep.withPhrase,
problem: PasswordProblem.phraseDoesNotFit,
),
);
await withPhrase(phrase: 'three words only');
expect(
cubit.state,
at(AccountRecoveryStep.withPhrase, problem: PasswordProblem.notAPhrase),
);
expect(anna.password, 'forgotten');
expect(auth.status, const SignedOut());
});
test("takes a recovery code in place of the authenticator's", () async {
cubit
..chosen(AccountRecoveryStep.withPhrase)
..recoveryCodeChosen(chosen: true);
expect(cubit.state.recoveryCode, isTrue);
await withPhrase(code: 'AAAA-0002');
expect(cubit.state, at(AccountRecoveryStep.done));
expect(anna.recoveryCodes, isNot(contains('AAAA-0002')));
});
});
group('without the recovery phrase', () {
Future<void> atNow(Future<void> Function() body) =>
withClock(Clock(() => now), body);
test(
"a code by mail starts the account's reset, saying when it takes "
'effect',
() => atNow(() async {
cubit.chosen(AccountRecoveryStep.startAddress);
expect(
await statesOf(
cubit,
() => cubit.startAddressEntered(' anna@example.org'),
),
[
at(AccountRecoveryStep.startAddress, busy: true),
at(AccountRecoveryStep.startCode, busy: true),
at(AccountRecoveryStep.startCode),
],
);
expect(await statesOf(cubit, () => cubit.startCodeEntered('CODE-9')), [
at(AccountRecoveryStep.startCode, busy: true),
at(
AccountRecoveryStep.startCode,
problem: PasswordProblem.wrongMailCode,
),
]);
expect(
await statesOf(
cubit,
() => cubit.startCodeEntered(gateway.codeFor('anna@example.org')),
),
[
at(AccountRecoveryStep.startCode, busy: true),
at(AccountRecoveryStep.started, busy: true),
at(AccountRecoveryStep.started),
],
);
expect(cubit.state.resetAt, now.add(fakeResetDelay));
expect(anna.accountReset, now.add(fakeResetDelay));
expect(anna.password, 'forgotten');
}),
);
test('is refused for an address without a password, and for what is '
'no address', () async {
cubit.chosen(AccountRecoveryStep.startAddress);
await cubit.startAddressEntered('ben');
expect(
cubit.state,
at(
AccountRecoveryStep.startAddress,
problem: PasswordProblem.notAnAddress,
),
);
await cubit.startAddressEntered('ben@example.org');
await cubit.startCodeEntered(gateway.codeFor('ben@example.org'));
expect(
cubit.state,
at(AccountRecoveryStep.startCode, problem: PasswordProblem.noPassword),
);
});
});
}

View file

@ -0,0 +1,333 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'states.dart';
void main() {
late FakeAuthGateway auth;
late FakePasswordGateway gateway;
late FakeAccountCrypto crypto;
late PasswordRepository passwords;
late FakePasswordAccount anna;
setUp(() async {
auth = FakeAuthGateway();
gateway = FakePasswordGateway(auth);
crypto = FakeAccountCrypto();
passwords = PasswordRepository(
gateway,
crypto,
MemoryAccountKeyStore(),
auth,
MemorySignInAddresses(),
);
anna = gateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: crypto,
);
await passwords.enter(
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
),
);
gateway.calls.clear();
});
tearDown(() => passwords.dispose());
group('what keeps the account', () {
test('is read, and read anew after a change made through the '
'repository wherever it was made', () async {
final cubit = AccountSecurityCubit(passwords);
addTearDown(cubit.close);
expect(await statesOf(cubit, cubit.load), [
isA<AccountSecurityState>()
.having((state) => state.failed, 'failed', isFalse)
.having((state) => state.security?.hasPassword, 'password', isTrue)
.having((state) => state.security?.finished, 'finished', isTrue)
.having((state) => state.security?.keysHere, 'keys here', isTrue)
.having(
(state) => state.security?.authenticators,
'authenticators',
hasLength(1),
),
]);
final added = await passwords.enrol(name: 'Laptop', label: 'anna');
await passwords.confirm(
added.id,
fakeTotpCode,
existingCode: fakeTotpCode,
);
await pumpEventQueue();
expect(cubit.state.security?.authenticators, hasLength(2));
});
test('says it failed, keeping what it read before', () async {
final cubit = AccountSecurityCubit(passwords);
addTearDown(cubit.close);
await cubit.load();
final before = cubit.state.security;
gateway.failing = const PasswordFailed(PasswordProblem.failed);
await cubit.load();
expect(cubit.state.failed, isTrue);
expect(cubit.state.security, before);
});
});
group('a step of the account section', () {
test('is busy, then holds its result', () async {
final cubit = SecurityStepCubit<int>();
addTearDown(cubit.close);
expect(await statesOf(cubit, () => cubit.run(() async => 7)), [
const SecurityStepState<int>(busy: true),
const SecurityStepState<int>(result: 7),
]);
});
test('is busy, then shows its refusal', () async {
final cubit = SecurityStepCubit<int>();
addTearDown(cubit.close);
final went = await cubit.run(
() async => throw const PasswordFailed(PasswordProblem.wrongCode),
);
expect(went, isFalse);
expect(
cubit.state,
const SecurityStepState<int>(
problem: PasswordFailed(PasswordProblem.wrongCode),
),
);
});
test('says two entries differ', () async {
final cubit = SecurityStepCubit<bool>();
addTearDown(cubit.close);
expect(await statesNow(cubit, cubit.mismatched), [
const SecurityStepState<bool>(mismatch: true),
]);
});
});
group('adding an authenticator', () {
/// A state of adding one at [step], [busy] or not, with [problem] or
/// none.
Matcher at(
AddAuthenticatorStep step, {
bool busy = false,
PasswordProblem? problem,
}) => isA<AddAuthenticatorState>()
.having((state) => state.step, 'step', step)
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem?.problem, 'problem', problem);
test('takes its code and one from an authenticator added before, '
'refusing a wrong one, nothing added meanwhile', () async {
final cubit = AddAuthenticatorCubit(
passwords,
label: 'anna@example.org',
name: 'Laptop',
needsExistingCode: true,
);
addTearDown(cubit.close);
expect(await statesOf(cubit, cubit.start), [
at(AddAuthenticatorStep.enrolling, busy: true),
at(AddAuthenticatorStep.code),
]);
expect(cubit.state.setup?.uri, startsWith('otpauth://totp/'));
expect(
await statesOf(
cubit,
() => cubit.codeEntered(fakeTotpCode, existingCode: '000000'),
),
[
at(AddAuthenticatorStep.code, busy: true),
at(
AddAuthenticatorStep.code,
problem: PasswordProblem.wrongExistingCode,
),
],
);
await cubit.codeEntered('000000', existingCode: fakeTotpCode);
expect(
cubit.state,
at(AddAuthenticatorStep.code, problem: PasswordProblem.wrongCode),
);
expect(anna.authenticators.where((each) => each.confirmed), hasLength(1));
expect(
await statesOf(
cubit,
() => cubit.codeEntered(fakeTotpCode, existingCode: fakeTotpCode),
),
[
at(AddAuthenticatorStep.code, busy: true),
at(AddAuthenticatorStep.done),
],
);
expect(anna.authenticators.where((each) => each.confirmed), hasLength(2));
});
test("the first after a reset brings recovery codes, without another's "
'code', () async {
anna.authenticators.clear();
final cubit = AddAuthenticatorCubit(
passwords,
label: 'anna@example.org',
name: 'Phone',
needsExistingCode: false,
);
addTearDown(cubit.close);
await cubit.start();
await cubit.codeEntered(fakeTotpCode, existingCode: 'ignored');
expect(cubit.state, at(AddAuthenticatorStep.recoveryCodes));
expect(cubit.state.recoveryCodes, hasLength(10));
});
test("says so where it can't be enrolled", () async {
gateway.failing = const PasswordFailed(
PasswordProblem.tooManyAuthenticators,
);
final cubit = AddAuthenticatorCubit(
passwords,
label: 'anna@example.org',
name: 'Laptop',
needsExistingCode: true,
);
addTearDown(cubit.close);
await cubit.start();
expect(
cubit.state,
at(
AddAuthenticatorStep.enrolling,
problem: PasswordProblem.tooManyAuthenticators,
),
);
});
});
group('a new recovery phrase', () {
/// A state of making one at [step], [busy] or not, with [problem] or
/// none.
Matcher at(
NewPhraseStep step, {
bool busy = false,
PasswordProblem? problem,
bool phraseWrong = false,
}) => isA<NewPhraseState>()
.having((state) => state.step, 'step', step)
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem?.problem, 'problem', problem)
.having((state) => state.phraseWrong, 'phraseWrong', phraseWrong);
/// The words the check asks for, typed back right.
List<String> wordsAsked(NewPhraseState state) => [
for (final position in state.checked) wordsOf(state.phrase)[position],
];
test('is shown, three words typed back, then kept for the password and '
'a code alone; the old one opens nothing any more', () async {
final cubit = NewPhraseCubit(passwords);
addTearDown(cubit.close);
final before = anna.recoveryKeys;
expect(await statesOf(cubit, cubit.start), [
at(NewPhraseStep.making, busy: true),
at(NewPhraseStep.shown),
]);
expect(cubit.state.phrase, otherFakePhrase);
expect(cubit.state.toString(), isNot(contains('zoo')));
expect(await statesNow(cubit, cubit.writtenDown), [
at(NewPhraseStep.check),
]);
expect(cubit.state.checked, hasLength(3));
await cubit.checked(
['wrong', 'wrong', 'wrong'],
password: 'correct horse',
code: fakeTotpCode,
);
expect(cubit.state, at(NewPhraseStep.check, phraseWrong: true));
// Nothing reached the instance before the words were right.
expect(gateway.calls, isNot(contains('beginChange')));
expect(
await statesOf(
cubit,
() => cubit.checked(
wordsAsked(cubit.state),
password: 'wrong',
code: fakeTotpCode,
),
),
[
at(NewPhraseStep.check, busy: true),
at(NewPhraseStep.check, problem: PasswordProblem.wrongCredentials),
],
);
await cubit.checked(
wordsAsked(cubit.state),
password: 'correct horse',
code: '000000',
);
expect(
cubit.state,
at(NewPhraseStep.check, problem: PasswordProblem.wrongCredentials),
);
expect(anna.recoveryKeys, same(before));
expect(
await statesOf(
cubit,
() => cubit.checked(
wordsAsked(cubit.state),
password: 'correct horse',
code: fakeTotpCode,
),
),
[
at(NewPhraseStep.check, busy: true),
at(NewPhraseStep.done),
],
);
expect(anna.recoveryKeys, isNot(same(before)));
});
test('is made only where the keys are on the device', () async {
final elsewhere = PasswordRepository(
gateway,
crypto,
MemoryAccountKeyStore(),
auth,
MemorySignInAddresses(),
);
addTearDown(elsewhere.dispose);
final cubit = NewPhraseCubit(elsewhere);
addTearDown(cubit.close);
await cubit.start();
expect(
cubit.state,
at(NewPhraseStep.making, problem: PasswordProblem.keysMissing),
);
});
});
}

View file

@ -0,0 +1,331 @@
import 'dart:typed_data';
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 'states.dart';
/// A state of setting up at [step], [busy] or not, with [problem] or none.
Matcher at(
AccountSetupStep step, {
bool busy = false,
PasswordProblem? problem,
}) => isA<AccountSetupState>()
.having((state) => state.step, 'step', step)
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem?.problem, 'problem', problem);
void main() {
late FakeAuthGateway auth;
late FakePasswordGateway gateway;
late FakeAccountCrypto crypto;
late PasswordRepository passwords;
late DateTime now;
setUp(() {
now = DateTime.utc(2026, 10, 20, 12);
auth = FakeAuthGateway();
gateway = FakePasswordGateway(auth);
crypto = FakeAccountCrypto();
passwords = PasswordRepository(
gateway,
crypto,
MemoryAccountKeyStore(),
auth,
MemorySignInAddresses(),
);
});
tearDown(() => passwords.dispose());
AccountSetupCubit setUpAs(
AccountSetupMode mode, {
String? address,
bool hasAddress = false,
String? phrase,
}) {
final cubit = AccountSetupCubit(
passwords,
mode: mode,
language: 'en',
address: address,
hasAddress: hasAddress,
phrase: phrase,
);
addTearDown(cubit.close);
return cubit;
}
/// The words the check asks for, typed back right.
List<String> wordsAsked(AccountSetupState state) => [
for (final position in state.checked) wordsOf(state.phrase)[position],
];
test('creates an account: the address, the password twice, the phrase '
'shown and three words typed back, then the code by mail, the '
'authenticator and the recovery codes, signed in only at the '
'end', () async {
final cubit = setUpAs(AccountSetupMode.signUp);
expect(cubit.state, at(AccountSetupStep.address));
expect(await statesNow(cubit, () => cubit.addressEntered('anna')), [
at(AccountSetupStep.address, problem: PasswordProblem.notAnAddress),
]);
expect(
await statesNow(cubit, () => cubit.addressEntered(' anna@example.org ')),
[at(AccountSetupStep.password)],
);
expect(cubit.state.address, 'anna@example.org');
await cubit.passwordEntered('correct horse', 'correct hrose');
expect(cubit.state.passwordsDiffer, isTrue);
expect(gateway.calls, isEmpty);
expect(
await statesOf(
cubit,
() => cubit.passwordEntered('correct horse', 'correct horse'),
),
[
isA<AccountSetupState>().having(
(state) => state.passwordsDiffer,
'passwordsDiffer',
isFalse,
),
at(AccountSetupStep.password, busy: true),
at(AccountSetupStep.phrase, busy: true),
at(AccountSetupStep.phrase),
],
);
expect(cubit.state.phrase, fakePhrase);
cubit.phraseWrittenDown();
expect(cubit.state, at(AccountSetupStep.phraseCheck));
expect(cubit.state.checked, hasLength(3));
await cubit.phraseChecked(['wrong', 'wrong', 'wrong']);
expect(cubit.state.phraseWrong, isTrue);
// Nothing reached the instance before the phrase was checked.
expect(gateway.calls, isEmpty);
expect(
await statesOf(cubit, () => cubit.phraseChecked(wordsAsked(cubit.state))),
[
isA<AccountSetupState>().having(
(state) => state.phraseWrong,
'phraseWrong',
isFalse,
),
at(AccountSetupStep.phraseCheck, busy: true),
at(AccountSetupStep.mailCode, busy: true),
at(AccountSetupStep.mailCode),
],
);
expect(gateway.calls, ['sendCode']);
await cubit.mailCodeEntered('CODE-9');
expect(
cubit.state,
at(AccountSetupStep.mailCode, problem: PasswordProblem.wrongMailCode),
);
expect(
await statesOf(
cubit,
() => cubit.mailCodeEntered(gateway.codeFor('anna@example.org')),
),
[
at(AccountSetupStep.mailCode, busy: true),
at(AccountSetupStep.authenticator, busy: true),
at(AccountSetupStep.authenticator),
],
);
expect(auth.status, const SignedOut());
await cubit.authenticatorCodeEntered('000000');
expect(
cubit.state,
at(AccountSetupStep.authenticator, problem: PasswordProblem.wrongCode),
);
await cubit.authenticatorCodeEntered(fakeTotpCode);
expect(cubit.state, at(AccountSetupStep.recoveryCodes));
expect(cubit.state.recoveryCodes, hasLength(10));
expect(auth.status, const SignedOut());
expect(await statesOf(cubit, cubit.recoveryCodesKept), [
at(AccountSetupStep.recoveryCodes, busy: true),
at(AccountSetupStep.done, busy: true),
at(AccountSetupStep.done),
]);
final account = gateway.accounts['anna@example.org']!;
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
expect(account.password, 'correct horse');
});
test('an address that has an account already goes back to the address, '
'saying so', () async {
gateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: crypto,
);
final cubit = setUpAs(AccountSetupMode.signUp)
..addressEntered('anna@example.org');
await cubit.passwordEntered('another horse', 'another horse');
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(
cubit.state,
at(AccountSetupStep.address, problem: PasswordProblem.passwordExists),
);
expect(auth.status, const SignedOut());
});
test('gives a signed-in anonymous person a password with an address a '
'code proves, the session staying theirs', () async {
await auth.signInAnonymously();
final person = (auth.status as SignedIn).authUserId;
final cubit = setUpAs(AccountSetupMode.setPassword)
..addressEntered('anna@example.org');
await cubit.passwordEntered('correct horse', 'correct horse');
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
expect(cubit.state, at(AccountSetupStep.mailCode));
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
await cubit.authenticatorCodeEntered(fakeTotpCode);
await cubit.recoveryCodesKept();
expect(cubit.state, at(AccountSetupStep.done));
expect(gateway.accountOf(person).finished, isTrue);
expect((auth.status as SignedIn).authUserId, person);
});
test("refuses another account's address to a signed-in person, back at "
'the address', () async {
gateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: crypto,
);
await auth.signInAnonymously();
final cubit = setUpAs(AccountSetupMode.setPassword)
..addressEntered('anna@example.org');
await cubit.passwordEntered('another horse', 'another horse');
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(
cubit.state,
at(AccountSetupStep.address, problem: PasswordProblem.addressTaken),
);
});
group("finishing an account's reset", () {
late FakePasswordAccount account;
setUp(() async {
account = gateway.addAccount(
'anna@example.org',
password: 'forgotten',
crypto: crypto,
);
await withClock(Clock(() => now), () async {
final request = await passwords.sendCode(
'anna@example.org',
language: 'en',
);
await passwords.startAccountReset(
address: 'anna@example.org',
language: 'en',
request: request,
code: gateway.codeFor('anna@example.org'),
);
});
});
Future<void> atNow(Future<void> Function() body) =>
withClock(Clock(() => now), body);
test(
'is refused before it is due, saying when, and after it gives new '
'keys, a new phrase and an authenticator, then signs in',
() => atNow(
() async {
final cubit = setUpAs(
AccountSetupMode.finishReset,
address: 'anna@example.org',
);
expect(cubit.state, at(AccountSetupStep.password));
await cubit.passwordEntered('new horse', 'new horse');
expect(cubit.state, at(AccountSetupStep.phrase));
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(
cubit.state,
at(AccountSetupStep.mailCode, problem: PasswordProblem.notDue),
);
expect(cubit.state.problem?.takesEffectAt, now.add(fakeResetDelay));
now = now.add(fakeResetDelay);
await cubit.mailCodeResent();
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(cubit.state, at(AccountSetupStep.authenticator));
await cubit.authenticatorCodeEntered(fakeTotpCode);
await cubit.recoveryCodesKept();
expect(cubit.state, at(AccountSetupStep.done));
expect(account.password, 'new horse');
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
},
),
);
test(
'with the recovery phrase keeps the old keys: no new phrase to '
'write down',
() => atNow(() async {
final keysBefore = Uint8List.sublistView(account.encryptionPublicKey);
now = now.add(fakeResetDelay);
final cubit = setUpAs(
AccountSetupMode.finishReset,
address: 'anna@example.org',
phrase: fakePhraseForTests,
);
expect(
await statesOf(
cubit,
() => cubit.passwordEntered('new horse', 'new horse'),
),
[
// A cubit's first state goes out even when it equals the one it
// started with.
at(AccountSetupStep.password),
at(AccountSetupStep.password, busy: true),
at(AccountSetupStep.mailCode, busy: true),
at(AccountSetupStep.mailCode),
],
);
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
await cubit.authenticatorCodeEntered(fakeTotpCode);
await cubit.recoveryCodesKept();
expect(cubit.state, at(AccountSetupStep.done));
expect(Uint8List.sublistView(account.encryptionPublicKey), keysBefore);
expect(account.password, 'new horse');
}),
);
});
}

View file

@ -0,0 +1,326 @@
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 'states.dart';
/// A state of signing in at [step], [busy] or not, with [problem] or none.
Matcher at(
PasswordSignInStep step, {
bool busy = false,
PasswordProblem? problem,
}) => isA<PasswordSignInState>()
.having((state) => state.step, 'step', step)
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem?.problem, 'problem', problem);
void main() {
late FakeAuthGateway auth;
late FakePasswordGateway gateway;
late FakeAccountCrypto crypto;
late PasswordRepository passwords;
late PasswordSignInCubit cubit;
late DateTime now;
setUp(() {
now = DateTime.utc(2026, 10, 20, 12);
auth = FakeAuthGateway();
gateway = FakePasswordGateway(auth);
crypto = FakeAccountCrypto();
passwords = PasswordRepository(
gateway,
crypto,
MemoryAccountKeyStore(),
auth,
MemorySignInAddresses(),
);
cubit = PasswordSignInCubit(passwords, language: 'en');
});
tearDown(() async {
await cubit.close();
await passwords.dispose();
});
/// Runs [body] at [now], which a test moves on.
Future<void> atNow(Future<void> Function() body) =>
withClock(Clock(() => now), body);
FakePasswordAccount anna({bool finished = true}) => gateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: crypto,
finished: finished,
);
test('signs in with the address, the password and a code: the code asked '
'for once the password is unlocked, signed in only at the end', () async {
final account = anna();
expect(
await statesOf(
cubit,
() => cubit.credentialsEntered(' anna@example.org', 'correct horse'),
),
[
at(PasswordSignInStep.credentials, busy: true),
at(PasswordSignInStep.secondFactor, busy: true),
at(PasswordSignInStep.secondFactor),
],
);
expect(cubit.state.address, 'anna@example.org');
expect(auth.status, const SignedOut());
expect(await statesOf(cubit, () => cubit.codeEntered(fakeTotpCode)), [
at(PasswordSignInStep.secondFactor, busy: true),
at(PasswordSignInStep.done, busy: true),
at(PasswordSignInStep.done),
]);
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
});
test('refuses a wrong password and a wrong code alike, on the code, '
'signing nobody in', () async {
anna();
await cubit.credentialsEntered('anna@example.org', 'wrong');
expect(await statesOf(cubit, () => cubit.codeEntered(fakeTotpCode)), [
at(PasswordSignInStep.secondFactor, busy: true),
at(
PasswordSignInStep.secondFactor,
problem: PasswordProblem.wrongCredentials,
),
]);
expect(await statesNow(cubit, cubit.backToCredentials), [
at(PasswordSignInStep.credentials),
]);
await cubit.credentialsEntered('anna@example.org', 'correct horse');
expect(await statesOf(cubit, () => cubit.codeEntered('000000')), [
at(PasswordSignInStep.secondFactor, busy: true),
at(
PasswordSignInStep.secondFactor,
problem: PasswordProblem.wrongCredentials,
),
]);
expect(auth.status, const SignedOut());
});
test('signs in with a recovery code, chosen in place of the '
"authenticator's, which goes once used", () async {
final account = anna();
await cubit.credentialsEntered('anna@example.org', 'correct horse');
expect(
await statesNow(cubit, () => cubit.recoveryCodeChosen(chosen: true)),
[
isA<PasswordSignInState>()
.having(
(state) => state.step,
'step',
PasswordSignInStep.secondFactor,
)
.having((state) => state.recoveryCode, 'recoveryCode', isTrue),
],
);
await cubit.codeEntered('aaaa-0001');
expect(cubit.state, at(PasswordSignInStep.done));
expect(account.recoveryCodes, isNot(contains('AAAA-0001')));
});
test("refuses an instance that can't prove it knows the password, "
'signing nobody in', () async {
anna();
gateway.provesPassword = false;
await cubit.credentialsEntered('anna@example.org', 'correct horse');
await cubit.codeEntered(fakeTotpCode);
expect(
cubit.state,
at(
PasswordSignInStep.secondFactor,
problem: PasswordProblem.instanceUnproven,
),
);
expect(auth.status, const SignedOut());
});
test('says so without a connection, on the step it was on', () async {
anna();
gateway.failing = const PasswordFailed(PasswordProblem.failed);
expect(
await statesOf(
cubit,
() => cubit.credentialsEntered('anna@example.org', 'correct horse'),
),
[
at(PasswordSignInStep.credentials, busy: true),
at(PasswordSignInStep.credentials, problem: PasswordProblem.failed),
],
);
});
test('an account never finished: a code by mail signs in, then an '
'authenticator is set up and its recovery codes shown before the '
'session is put in place', () async {
final account = anna(finished: false);
await cubit.credentialsEntered('anna@example.org', 'correct horse');
expect(await statesOf(cubit, () => cubit.codeEntered(fakeTotpCode)), [
at(PasswordSignInStep.secondFactor, busy: true),
at(PasswordSignInStep.mailCode, busy: true),
at(PasswordSignInStep.mailCode),
]);
expect(gateway.mailed.single.address, 'anna@example.org');
expect(await statesOf(cubit, () => cubit.mailCodeEntered('CODE-9')), [
at(PasswordSignInStep.mailCode, busy: true),
at(PasswordSignInStep.mailCode, problem: PasswordProblem.wrongMailCode),
]);
expect(
await statesOf(
cubit,
() => cubit.mailCodeEntered(gateway.codeFor('anna@example.org')),
),
[
at(PasswordSignInStep.mailCode, busy: true),
at(PasswordSignInStep.authenticator, busy: true),
at(PasswordSignInStep.authenticator),
],
);
expect(cubit.state.authenticator?.uri, startsWith('otpauth://totp/'));
expect(auth.status, const SignedOut());
expect(
await statesOf(cubit, () => cubit.authenticatorCodeEntered('000000')),
[
at(PasswordSignInStep.authenticator, busy: true),
at(
PasswordSignInStep.authenticator,
problem: PasswordProblem.wrongCode,
),
],
);
expect(
await statesOf(
cubit,
() => cubit.authenticatorCodeEntered(fakeTotpCode),
),
[
at(PasswordSignInStep.authenticator, busy: true),
at(PasswordSignInStep.recoveryCodes, busy: true),
at(PasswordSignInStep.recoveryCodes),
],
);
expect(cubit.state.recoveryCodes, hasLength(10));
expect(auth.status, const SignedOut());
expect(await statesOf(cubit, cubit.recoveryCodesKept), [
at(PasswordSignInStep.recoveryCodes, busy: true),
at(PasswordSignInStep.done, busy: true),
at(PasswordSignInStep.done),
]);
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
expect(account.finished, isTrue);
});
test(
'a lost authenticator: its reset asked for with a code by mail says '
'when it takes effect; without a code nothing signs in until then, '
'and after it the password alone does, an authenticator set up '
'first',
() => atNow(() async {
final account = anna();
await cubit.credentialsEntered('anna@example.org', 'correct horse');
expect(await statesNow(cubit, cubit.authenticatorLost), [
at(PasswordSignInStep.lostAuthenticator),
]);
expect(await statesOf(cubit, cubit.resetByMailChosen), [
at(PasswordSignInStep.lostAuthenticator, busy: true),
at(PasswordSignInStep.resetMailCode, busy: true),
at(PasswordSignInStep.resetMailCode),
]);
expect(
await statesOf(cubit, () => cubit.resetMailCodeEntered('CODE-9')),
[
at(PasswordSignInStep.resetMailCode, busy: true),
at(
PasswordSignInStep.resetMailCode,
problem: PasswordProblem.wrongMailCode,
),
],
);
await cubit.resetMailCodeEntered(gateway.codeFor('anna@example.org'));
expect(cubit.state, at(PasswordSignInStep.resetRequested));
expect(cubit.state.resetAt, now.add(fakeResetDelay));
await cubit.withoutCodeChosen();
expect(
cubit.state,
at(PasswordSignInStep.resetRequested, problem: PasswordProblem.notDue),
);
expect(cubit.state.problem?.takesEffectAt, now.add(fakeResetDelay));
expect(auth.status, const SignedOut());
now = now.add(fakeResetDelay);
await cubit.withoutCodeChosen();
expect(cubit.state, at(PasswordSignInStep.authenticator));
expect(account.authenticators.where((each) => each.confirmed), isEmpty);
await cubit.authenticatorCodeEntered(fakeTotpCode);
await cubit.recoveryCodesKept();
expect(cubit.state, at(PasswordSignInStep.done));
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
}),
);
test(
"a lost authenticator's reset asked for with the recovery phrase, "
'which a wrong one refuses',
() => atNow(() async {
anna();
await cubit.credentialsEntered('anna@example.org', 'correct horse');
cubit
..authenticatorLost()
..resetByPhraseChosen();
expect(cubit.state, at(PasswordSignInStep.resetPhrase));
expect(
await statesOf(
cubit,
() => cubit.resetPhraseEntered(otherFakePhrase),
),
[
at(PasswordSignInStep.resetPhrase, busy: true),
at(
PasswordSignInStep.resetPhrase,
problem: PasswordProblem.phraseDoesNotFit,
),
],
);
await cubit.resetPhraseEntered('three words only');
expect(
cubit.state,
at(PasswordSignInStep.resetPhrase, problem: PasswordProblem.notAPhrase),
);
await cubit.resetPhraseEntered(fakePhraseForTests);
expect(cubit.state, at(PasswordSignInStep.resetRequested));
expect(cubit.state.resetAt, now.add(fakeResetDelay));
}),
);
}

View file

@ -0,0 +1,28 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// The states [cubit] emits while [step] runs, in order.
Future<List<S>> statesOf<S>(
BlocBase<S> cubit,
Future<void> Function() step,
) async {
final states = <S>[];
final listening = cubit.stream.listen(states.add);
await step();
// A cubit's stream hands its states on in a microtask.
await Future<void>.delayed(Duration.zero);
await listening.cancel();
return states;
}
/// The states [cubit] emits as [step] (not awaited: it emits at once).
Future<List<S>> statesNow<S>(BlocBase<S> cubit, void Function() step) =>
statesOf(cubit, () async => step());
/// A refusal of [problem].
Matcher refusedAs(PasswordProblem problem) => isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
problem,
);