halkyon/packages/halkyon_app/test/simplelogin/bloc/simplelogin_cubit_test.dart
Tizian 「ティツィアーン」 1b79214764
All checks were successful
CI / check (push) Successful in 31m14s
CI / deploy (push) Has been skipped
feat(app): a reset of the keys without the phrase, its banner, and the keys' proof key for a password that signs in (ADR 0020, D31–D35)
SimpleLogin's sheet, its password and its recovery phrase both lost:
under the lost-password step's phrase field, Lost your recovery phrase
too? says what stays and what is lost and asks for new keys
(`keys.startReset`) with the sign-in of a moment ago, which the device
then drops. The next sign-in says when while it waits, and once due
offers new keys, the sign-up's own password and phrase steps
(`keys.completeReset`), or the password, which calls it off (D31).

Every app's home shows `KeysResetBanner` where "too old" shows, and the
account section's SimpleLogin row the same words (`KeysResetNotice`),
each with Call it off, the password's proof alone
(`keys.callOffResets`, D32), and, once due on a device that holds the
keys, Finish here, which wraps the same keys anew (D33, D35). Both show
only a reset of an account that signs in with SimpleLogin alone; ADR
0017's resets show in its own section, which now shares `AlertBox` with
them.

An account whose password signs in has no Forgot your password? there,
and is asked its password and a code once while its keys lack the proof
key, at a sign-in or on adding SimpleLogin (`password.setProofKey`,
D34). `PasswordUnlock` carries the proof key, which its forgetting
leaves, and the keys' password proves for a purpose given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 23:24:44 +02:00

618 lines
20 KiB
Dart

import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
/// A state at [step], [busy] or not, with [problem] or none.
Matcher at(SimpleLoginStep step, {bool busy = false, Exception? problem}) =>
isA<SimpleLoginState>()
.having((state) => state.step, 'step', step)
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem, 'problem', problem);
const password = 'correct horse battery';
void main() {
late FakeAuthGateway auth;
late FakeSimpleLoginGateway gateway;
late FakeOidcBrowser browser;
late FakeAccountCrypto crypto;
late MemoryAccountKeyStore keys;
late SimpleLoginRepository simpleLogin;
setUp(() {
auth = FakeAuthGateway(status: const SignedOut());
gateway = FakeSimpleLoginGateway(auth);
browser = FakeOidcBrowser();
crypto = FakeAccountCrypto();
keys = MemoryAccountKeyStore();
simpleLogin = SimpleLoginRepository(
gateway,
browser,
crypto,
keys,
auth,
callbackScheme: 'app.halkyon.test',
loopbackPort: 43823,
platform: TargetPlatform.android,
);
});
SimpleLoginCubit cubitFor({bool link = false}) {
final cubit = SimpleLoginCubit(simpleLogin, link: link);
addTearDown(cubit.close);
return cubit;
}
/// The words the check asks for, typed back right.
List<String> wordsAsked(SimpleLoginState state) => [
for (final position in state.checked) wordsOf(state.phrase)[position],
];
/// Makes the keys with [password] and types the phrase back.
Future<void> makeKeys(SimpleLoginCubit cubit) async {
await cubit.passwordEntered(password, password);
expect(cubit.state, at(SimpleLoginStep.phrase));
expect(cubit.state.phrase, fakePhrase);
cubit.phraseWrittenDown();
expect(cubit.state, at(SimpleLoginStep.phraseCheck));
await cubit.phraseChecked(wordsAsked(cubit.state));
}
group('someone new', () {
test('creates an account: the suggested handle checked, the password '
'twice, the phrase shown and three words typed back, signed in '
'only at the end', () async {
final cubit = cubitFor();
expect(cubit.state, at(SimpleLoginStep.browser));
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.newcomer));
expect(cubit.state.handle, 'anna_mueller');
cubit.createChosen();
expect(cubit.state, at(SimpleLoginStep.handle));
expect(await cubit.checkHandle('anna_mueller'), isNull);
await cubit.handleEntered(' @anna_mueller ');
expect(cubit.state, at(SimpleLoginStep.password));
expect(cubit.state.handle, 'anna_mueller');
expect(
gateway.calls,
isNot(contains('checkHandle')),
reason: 'the instance suggests no handle anybody has',
);
await cubit.passwordEntered(password, 'correct horse batery');
expect(cubit.state.passwordsDiffer, isTrue);
await cubit.passwordEntered('too short', 'too short');
expect(
cubit.state,
at(
SimpleLoginStep.password,
problem: const PasswordFailed(PasswordProblem.passwordTooShort),
),
);
await cubit.passwordEntered(password, password);
cubit.phraseWrittenDown();
await cubit.phraseChecked(['wrong', 'words', 'typed']);
expect(cubit.state.phraseWrong, isTrue);
cubit.phraseShownAgain();
expect(cubit.state, at(SimpleLoginStep.phrase));
expect(gateway.calls, isNot(contains('signUp')));
expect(auth.status, const SignedOut());
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'person-1', isIdentified: true),
);
expect(gateway.accounts['person-1']!.handle, 'anna_mueller');
expect(keys.kept.keys.toList(), ['person-1']);
});
test('checks the handles typed, under the sign-in, each asked of the '
'instance (ADR 0020, D30), and goes on with one', () async {
gateway.addAccount('person-9', subject: 'other', handle: 'anna');
final cubit = cubitFor();
await cubit.started();
cubit.createChosen();
expect(await cubit.checkHandle('anna'), HandleProblem.taken);
expect(await cubit.checkHandle('admin'), HandleProblem.reserved);
expect(await cubit.checkHandle('a-b'), HandleProblem.invalid);
expect(await cubit.checkHandle('anna_m'), isNull);
expect(
gateway.calls.where((call) => call == 'checkHandle'),
hasLength(4),
);
await cubit.handleEntered(' ');
expect(
cubit.state,
at(
SimpleLoginStep.handle,
problem: const SimpleLoginFailed(SimpleLoginProblem.handleInvalid),
),
);
await cubit.handleEntered('anna_m');
expect(cubit.state, at(SimpleLoginStep.password));
expect(cubit.state.handle, 'anna_m');
});
test('goes back to the handle when it was taken meanwhile, then on with '
'the keys made, once changed', () async {
final cubit = cubitFor();
await cubit.started();
cubit.createChosen();
await cubit.handleEntered('anna_mueller');
gateway.addAccount('person-9', subject: 'other', handle: 'anna_mueller');
await makeKeys(cubit);
expect(
cubit.state,
at(
SimpleLoginStep.handle,
problem: const SimpleLoginFailed(SimpleLoginProblem.handleTaken),
),
);
await cubit.handleEntered('anna_m');
expect(cubit.state, at(SimpleLoginStep.done));
final person = auth.usedSessions.single.authUserId;
expect(gateway.accounts[person]!.handle, 'anna_m');
});
test('links the account they have again, by its handle and recovery '
'phrase, refusing a phrase that does not fit', () async {
final material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-lost',
handle: 'anna',
keys: material.setup,
);
final cubit = cubitFor();
await cubit.started();
cubit.linkChosen();
expect(cubit.state, at(SimpleLoginStep.mend));
await cubit.mendEntered('anna', otherFakePhrase);
expect(
cubit.state,
at(
SimpleLoginStep.mend,
problem: const PasswordFailed(PasswordProblem.phraseDoesNotFit),
),
);
cubit.choiceShownAgain();
expect(cubit.state, at(SimpleLoginStep.newcomer));
cubit.linkChosen();
await cubit.mendEntered('@anna', fakePhrase);
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.linked, {'simplelogin-anna': 'person-9'});
expect(keys.kept, {'person-9': material.keys});
});
test('begins again once their sign-in ran out', () async {
final cubit = cubitFor();
await cubit.started();
cubit.linkChosen();
gateway.failing = const SimpleLoginFailed(SimpleLoginProblem.startAgain);
await cubit.mendEntered('anna', fakePhrase);
expect(
cubit.state,
at(
SimpleLoginStep.browser,
problem: const SimpleLoginFailed(SimpleLoginProblem.startAgain),
),
);
gateway.failing = null;
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.newcomer));
});
});
group('an account', () {
test('opens its keys with the password, refusing a wrong one', () async {
final material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.unlock));
expect(cubit.state.signedInAs, 'anna');
await cubit.unlockEntered('wrong horse battery');
expect(
cubit.state,
at(
SimpleLoginStep.unlock,
problem: const PasswordFailed(PasswordProblem.wrongCredentials),
),
);
expect(auth.status, const SignedOut());
await cubit.unlockEntered(password);
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'person-9', isIdentified: true),
);
expect(keys.kept, {'person-9': material.keys});
});
test('with the password lost, opens them with the recovery phrase under '
'a new password typed twice, and goes back to the password when '
'asked', () async {
final material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
final cubit = cubitFor();
await cubit.started();
cubit.forgotChosen();
expect(cubit.state, at(SimpleLoginStep.forgot));
cubit.unlockShownAgain();
expect(cubit.state, at(SimpleLoginStep.unlock));
cubit.forgotChosen();
const newPassword = 'a new horse battery';
await cubit.forgotEntered(fakePhrase, newPassword, 'a new horse');
expect(cubit.state, at(SimpleLoginStep.forgot));
expect(cubit.state.passwordsDiffer, isTrue);
expect(gateway.calls, isNot(contains('beginPhrase')));
await cubit.forgotEntered(otherFakePhrase, newPassword, newPassword);
expect(
cubit.state,
at(
SimpleLoginStep.forgot,
problem: const PasswordFailed(PasswordProblem.phraseDoesNotFit),
),
);
expect(cubit.state.passwordsDiffer, isFalse);
expect(auth.status, const SignedOut());
await cubit.forgotEntered(fakePhrase, newPassword, newPassword);
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'person-9', isIdentified: true),
);
expect(keys.kept, {'person-9': material.keys});
});
test('of before, without keys, makes them with the handle it '
'lacks', () async {
gateway.addAccount('person-9', subject: 'simplelogin-anna');
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.handle));
// Checked as the account it is, signed in with SimpleLogin.
expect(await cubit.checkHandle('anna'), isNull);
expect(gateway.calls, contains('checkAccountHandle'));
await cubit.handleEntered('anna');
expect(cubit.state, at(SimpleLoginStep.password));
expect(gateway.calls, isNot(contains('checkHandle')));
await makeKeys(cubit);
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.handle, 'anna');
expect(gateway.accounts['person-9']!.keys, isNotNull);
expect(auth.usedSessions.single.authUserId, 'person-9');
});
});
group('the password and the phrase lost: a reset of the keys (ADR 0020, '
'D31, D33)', () {
final asked = DateTime.utc(2026, 10, 24, 12);
final due = asked.add(const Duration(hours: 72));
late NewKeysMaterial material;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
});
test('is asked for from the lost password, saying when it takes '
'effect; nobody is signed in meanwhile', () async {
await withClock(Clock.fixed(asked), () async {
final cubit = cubitFor();
await cubit.started();
cubit
..forgotChosen()
..phraseLost();
expect(cubit.state, at(SimpleLoginStep.resetAsk));
cubit.forgotChosen();
expect(cubit.state, at(SimpleLoginStep.forgot));
cubit.phraseLost();
await cubit.resetAsked();
expect(cubit.state, at(SimpleLoginStep.resetAsked));
expect(cubit.state.resetAt, due);
expect(gateway.accounts['person-9']!.resetAt, due);
expect(auth.status, const SignedOut());
expect(keys.kept, isEmpty);
cubit.finished();
expect(cubit.state, at(SimpleLoginStep.closed));
});
});
test('waiting, the next sign-in says when, and the password opening the '
'keys calls it off', () async {
gateway.accounts['person-9']!.resetAt = due;
await withClock(Clock.fixed(asked), () async {
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.unlock));
expect(cubit.state.resetAt, due);
await cubit.unlockEntered(password);
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.resetAt, isNull);
});
});
test('due, gives new keys under a new password and phrase, or opens the '
'old ones with the password', () async {
gateway.accounts['person-9']!.resetAt = due;
await withClock(Clock.fixed(due), () async {
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.resetDue));
cubit.passwordChosen();
expect(cubit.state, at(SimpleLoginStep.unlock));
cubit.resetDueShownAgain();
expect(cubit.state, at(SimpleLoginStep.resetDue));
cubit.newKeysChosen();
expect(cubit.state, at(SimpleLoginStep.password));
expect(cubit.state.resetting, isTrue);
await makeKeys(cubit);
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'person-9', isIdentified: true),
);
final account = gateway.accounts['person-9']!;
expect(account.keys, isNot(material.setup));
expect(account.resetAt, isNull);
expect(keys.kept['person-9'], isNot(material.keys));
});
});
test('due on a device that holds the keys, signed in, keeps them under '
'the new password and phrase (D33)', () async {
gateway.accounts['person-9']!.resetAt = due;
await auth.useSession(const SessionGrant('person-9', 'session-9'));
keys.kept['person-9'] = material.keys;
await withClock(Clock.fixed(due), () async {
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.password));
expect(cubit.state.resetting, isTrue);
await makeKeys(cubit);
expect(cubit.state, at(SimpleLoginStep.done));
expect(keys.kept, {'person-9': material.keys});
expect(gateway.accounts['person-9']!.resetAt, isNull);
expect(auth.usedSessions, hasLength(1), reason: 'no other session');
});
});
test('waiting on a device that holds the keys, adding SimpleLogin is done '
'at once', () async {
gateway.accounts['person-9']!.resetAt = due;
await auth.useSession(const SessionGrant('person-9', 'session-9'));
await withClock(Clock.fixed(asked), () async {
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.resetAt, due);
});
});
});
group('a password that signs in too (ADR 0020, D34)', () {
late NewKeysMaterial material;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
passwordSigningIn: password,
proofKeyMissing: true,
);
});
test('opens the keys once it and a code set their proof key, a wrong '
'code refused', () async {
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.proofKey));
expect(cubit.state.signedInAs, 'anna');
await cubit.proofKeyEntered(password, '000000');
expect(
cubit.state,
at(
SimpleLoginStep.proofKey,
problem: const PasswordFailed(PasswordProblem.wrongCode),
),
);
expect(auth.status, const SignedOut());
await cubit.proofKeyEntered(password, fakeTotpCode);
expect(cubit.state, at(SimpleLoginStep.done));
expect(keys.kept, {'person-9': material.keys});
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
});
test('with the proof key, is asked alone, without the lost phrase, as its '
"resets are ADR 0017's", () async {
gateway.accounts['person-9']!.proofKeyMissing = false;
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.unlock));
expect(cubit.state.passwordSignsIn, isTrue);
});
test('adding SimpleLogin on a device signed in asks the same, and keeps '
'its session', () async {
await auth.useSession(const SessionGrant('person-9', 'session-9'));
keys.kept['person-9'] = material.keys;
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.proofKey));
await cubit.proofKeyEntered(password, fakeTotpCode);
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
expect(auth.usedSessions, hasLength(1), reason: 'no other session');
expect(keys.kept, {'person-9': material.keys});
});
});
group("SimpleLogin added to the account of the device's person", () {
test('a person without keys keeps those their device holds, and their '
'session, once the keys are set', () async {
await auth.switchToGitHub();
final deviceKeys = crypto.keysNumbered(1);
keys.kept['github-person'] = deviceKeys;
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.handle));
await cubit.handleEntered('anna');
await makeKeys(cubit);
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'github-person', isIdentified: true),
);
expect(keys.kept, {'github-person': deviceKeys});
expect(auth.usedSessions, isEmpty);
});
test('an account with keys is done at once', () async {
final material = await crypto.newKeys(password);
auth = FakeAuthGateway(
status: const SignedIn(authUserId: 'person-9', isIdentified: true),
);
gateway = FakeSimpleLoginGateway(auth);
gateway.accounts['person-9'] = FakeSimpleLoginAccount(
handle: 'anna',
keys: material.setup,
);
simpleLogin = SimpleLoginRepository(
gateway,
browser,
crypto,
keys,
auth,
callbackScheme: 'app.halkyon.test',
loopbackPort: 43823,
platform: TargetPlatform.android,
);
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.linked, {'simplelogin-anna': 'person-9'});
});
});
group('the browser', () {
test('closed, the sign-in ends with nothing done', () async {
browser.comesBack = () => null;
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.closed));
});
test("refused on SimpleLogin's page, says so and can begin "
'again', () async {
browser.comesBack = () => const OidcDenied('access_denied');
final cubit = cubitFor();
await cubit.started();
expect(
cubit.state,
at(
SimpleLoginStep.browser,
problem: const SimpleLoginFailed(SimpleLoginProblem.denied),
),
);
browser.comesBack = null;
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.newcomer));
});
test('is no longer waited for once the sheet closes', () async {
final waiting = Completer<OidcCallback?>();
browser.comesBack = () => waiting.future;
final cubit = SimpleLoginCubit(simpleLogin);
unawaited(cubit.started());
await Future<void>.delayed(Duration.zero);
expect(cubit.state, at(SimpleLoginStep.browser, busy: true));
await cubit.close();
expect(browser.cancelled, 1);
waiting.complete(null);
});
test('is no longer waited for once cancelled', () async {
final cubit = cubitFor();
await cubit.cancelled();
expect(browser.cancelled, 1);
});
});
}