halkyon/packages/halkyon_app/test/simplelogin/bloc/simplelogin_cubit_test.dart
Tizian 「ティツィアーン」 630965d920
feat(base)!: a handle at password sign-up (ADR 0020, D26, D28–D30)
Every new account gets a handle, and an account without one gets its
first as it sets a password. The handle is private until its person
goes public (D5).

- `passwordSignIn.signUp` takes a handle. `passwordSignIn.checkHandle`
  says whether a sign-up could take one, only while that sign-up's
  mailed code waits (`codeExpired` otherwise), counted per code request
  (30 an hour) and per network (60) (D29). Members ask the register.
- `password.set` takes the first handle of an account without one
  (D28); `SignInWays.handle` tells the app whether there is one.
- Each checks the handle before the code is used, so a refusal leaves
  the code for another try. An existing account at the address keeps
  its own handle where it has one, and the typed handle is still
  checked, so the answer says nothing about the address.
- `first_handle.dart` holds what SimpleLogin, the keys and passwords
  share: `refusalOfNewHandle`, `insertFirstProfile`, `hasHandle`.
- halkyon_app: one `HandleField` with its `HandleCheckCubit` (asked
  300 ms after typing pauses, only about the handle typed last; the
  instance's suggestion is free without asking) (D30). The password
  setup asks for the handle between the phrase check and the code, and
  a refusal at the code sends back to it. SimpleLogin's sign-up and
  keys steps use the same field and now check as it is typed.
- The module's README and ADR 0020 (D29, D30, the built state) amended.

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

386 lines
12 KiB
Dart

import 'dart:async';
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('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("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);
});
});
}