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:
parent
e126e1d4fb
commit
b51445a01a
6 changed files with 1238 additions and 1 deletions
|
|
@ -287,7 +287,12 @@ class PasswordRepository(
|
|||
/// What keeps the signed-in person's account.
|
||||
Future<AccountSecurity> security() async {
|
||||
final person = _signedIn();
|
||||
final (offers, ways) = await (_gateway.offers(), _gateway.ways()).wait;
|
||||
// Both asked at once, the first refusal going up as it is: a record's
|
||||
// `wait` would wrap the two in a ParallelWaitError, which no caller
|
||||
// takes for a refusal (the account section then showed nothing offline).
|
||||
final waysRead = _gateway.ways()..ignore();
|
||||
final offers = await _gateway.offers();
|
||||
final ways = await waysRead;
|
||||
return AccountSecurity(
|
||||
offered: offers.offered,
|
||||
hasAddress: ways.email,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
28
packages/halkyon_app/test/password/bloc/states.dart
Normal file
28
packages/halkyon_app/test/password/bloc/states.dart
Normal 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,
|
||||
);
|
||||
Loading…
Reference in a new issue