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>
This commit is contained in:
parent
87671208df
commit
630965d920
9 changed files with 2305 additions and 395 deletions
|
|
@ -192,6 +192,7 @@ void main() {
|
|||
),
|
||||
request: '0191f0c4-3333-7000-8000-000000000003',
|
||||
code: 'K7QXM2PD',
|
||||
handle: 'ben',
|
||||
);
|
||||
await gateway.useSession(grant);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
void main() {
|
||||
/// The handles asked of the instance, in order.
|
||||
late List<String> asked;
|
||||
|
||||
/// The instance's answers: 'admin' reserved, 'broken' unanswered,
|
||||
/// everything else free.
|
||||
Future<HandleProblem?> check(String handle) async {
|
||||
asked.add(handle);
|
||||
return switch (handle) {
|
||||
'admin' => HandleProblem.reserved,
|
||||
'broken' => throw StateError('offline'),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
setUp(() => asked = []);
|
||||
|
||||
test('asks once typing pauses, only for the handle typed last', () {
|
||||
fakeAsync((async) {
|
||||
final cubit = HandleCheckCubit(check)
|
||||
..typed('b')
|
||||
..typed('be')
|
||||
..typed('ben');
|
||||
expect(
|
||||
cubit.state,
|
||||
const HandleCheckState(handle: 'ben', check: HandleCheck.checking),
|
||||
);
|
||||
async.elapse(HandleCheckCubit.pause - const Duration(milliseconds: 1));
|
||||
expect(asked, isEmpty);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
expect(asked, ['ben']);
|
||||
expect(
|
||||
cubit.state,
|
||||
const HandleCheckState(handle: 'ben', check: HandleCheck.free),
|
||||
);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test('says why a handle is refused, for that handle only', () {
|
||||
fakeAsync((async) {
|
||||
final cubit = HandleCheckCubit(check)..typed(' @admin ');
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
|
||||
expect(
|
||||
cubit.state,
|
||||
const HandleCheckState(
|
||||
handle: 'admin',
|
||||
check: HandleCheck.refused,
|
||||
problem: HandleProblem.reserved,
|
||||
),
|
||||
);
|
||||
expect(cubit.state.refuses('@admin'), isTrue);
|
||||
expect(cubit.state.refuses('admin_2'), isFalse);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test("drops an answer for a handle that isn't typed any more", () {
|
||||
fakeAsync((async) {
|
||||
final slow = Completer<HandleProblem?>();
|
||||
final cubit = HandleCheckCubit((handle) {
|
||||
asked.add(handle);
|
||||
return handle == 'anna' ? slow.future : Future.value();
|
||||
})..typed('anna');
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
cubit.typed('anna_m');
|
||||
slow.complete(HandleProblem.taken);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(
|
||||
cubit.state,
|
||||
const HandleCheckState(handle: 'anna_m', check: HandleCheck.checking),
|
||||
);
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
expect(asked, ['anna', 'anna_m']);
|
||||
expect(cubit.state.check, HandleCheck.free);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test('asks nothing for nothing typed, nor again for the same handle', () {
|
||||
fakeAsync((async) {
|
||||
final cubit = HandleCheckCubit(check)..typed(' @ ');
|
||||
expect(cubit.state, const HandleCheckState());
|
||||
cubit.typed('ben');
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
cubit.typed('@ben ');
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
|
||||
expect(asked, ['ben']);
|
||||
expect(cubit.state.check, HandleCheck.free);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test("takes the instance's suggestion as free without asking", () {
|
||||
fakeAsync((async) {
|
||||
final cubit = HandleCheckCubit(check, free: 'anna_m')..typed('anna_m');
|
||||
expect(
|
||||
cubit.state,
|
||||
const HandleCheckState(handle: 'anna_m', check: HandleCheck.free),
|
||||
);
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
expect(asked, isEmpty);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test("says nothing it doesn't know where the instance can't be asked", () {
|
||||
fakeAsync((async) {
|
||||
final cubit = HandleCheckCubit(check)..typed('broken');
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
|
||||
expect(
|
||||
cubit.state,
|
||||
const HandleCheckState(handle: 'broken', check: HandleCheck.unknown),
|
||||
);
|
||||
expect(cubit.state.refuses('broken'), isFalse);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test('asks nothing once closed', () {
|
||||
fakeAsync((async) {
|
||||
unawaited((HandleCheckCubit(check)..typed('ben')).close());
|
||||
async.elapse(HandleCheckCubit.pause);
|
||||
expect(asked, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ void main() {
|
|||
AccountSetupMode mode, {
|
||||
String? address,
|
||||
bool hasAddress = false,
|
||||
bool hasHandle = false,
|
||||
String? phrase,
|
||||
}) {
|
||||
final cubit = AccountSetupCubit(
|
||||
|
|
@ -51,6 +52,7 @@ void main() {
|
|||
language: 'en',
|
||||
address: address,
|
||||
hasAddress: hasAddress,
|
||||
hasHandle: hasHandle,
|
||||
phrase: phrase,
|
||||
);
|
||||
addTearDown(cubit.close);
|
||||
|
|
@ -63,9 +65,9 @@ void main() {
|
|||
];
|
||||
|
||||
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 {
|
||||
'shown and three words typed back, then the handle while the code '
|
||||
'mailed travels (ADR 0020, D29), the code, the authenticator and the '
|
||||
'recovery codes, signed in only at the end', () async {
|
||||
final cubit = setUpAs(AccountSetupMode.signUp);
|
||||
expect(cubit.state, at(AccountSetupStep.address));
|
||||
|
||||
|
|
@ -121,12 +123,29 @@ void main() {
|
|||
isFalse,
|
||||
),
|
||||
at(AccountSetupStep.phraseCheck, busy: true),
|
||||
at(AccountSetupStep.mailCode, busy: true),
|
||||
at(AccountSetupStep.mailCode),
|
||||
at(AccountSetupStep.handle, busy: true),
|
||||
at(AccountSetupStep.handle),
|
||||
],
|
||||
);
|
||||
expect(gateway.calls, ['sendCode']);
|
||||
|
||||
// Checked as it is typed, against the code mailed for it.
|
||||
expect(await cubit.checkHandle('admin'), HandleProblem.reserved);
|
||||
expect(await cubit.checkHandle('anna'), isNull);
|
||||
expect(gateway.calls, [
|
||||
'sendCode',
|
||||
'checkSignUpHandle',
|
||||
'checkSignUpHandle',
|
||||
]);
|
||||
cubit.handleEntered(' @ ');
|
||||
expect(
|
||||
cubit.state,
|
||||
at(AccountSetupStep.handle, problem: PasswordProblem.handleInvalid),
|
||||
);
|
||||
cubit.handleEntered(' @anna ');
|
||||
expect(cubit.state, at(AccountSetupStep.mailCode));
|
||||
expect(cubit.state.handle, 'anna');
|
||||
|
||||
await cubit.mailCodeEntered('CODE-9');
|
||||
expect(
|
||||
cubit.state,
|
||||
|
|
@ -166,6 +185,35 @@ void main() {
|
|||
SignedIn(authUserId: account.person, isIdentified: true),
|
||||
);
|
||||
expect(account.password, 'correct horse');
|
||||
expect(gateway.handles[account.person], 'anna');
|
||||
});
|
||||
|
||||
test('a handle taken meanwhile goes back to the handle, saying so, the '
|
||||
'code left for the next', () async {
|
||||
final cubit = setUpAs(AccountSetupMode.signUp)
|
||||
..addressEntered('anna@example.org');
|
||||
await cubit.passwordEntered('correct horse', 'correct horse');
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
expect(await cubit.checkHandle('anna'), isNull);
|
||||
cubit.handleEntered('anna');
|
||||
gateway.handles['someone-else'] = 'Anna';
|
||||
final code = gateway.codeFor('anna@example.org');
|
||||
|
||||
await cubit.mailCodeEntered(code);
|
||||
expect(
|
||||
cubit.state,
|
||||
at(AccountSetupStep.handle, problem: PasswordProblem.handleTaken),
|
||||
);
|
||||
expect(gateway.accounts, isEmpty);
|
||||
|
||||
cubit.handleEntered('anna_m');
|
||||
await cubit.mailCodeEntered(code);
|
||||
expect(cubit.state, at(AccountSetupStep.authenticator));
|
||||
expect(
|
||||
gateway.handles[gateway.accounts['anna@example.org']!.person],
|
||||
'anna_m',
|
||||
);
|
||||
});
|
||||
|
||||
test('an address that has an account already goes back to the address, '
|
||||
|
|
@ -180,6 +228,7 @@ void main() {
|
|||
await cubit.passwordEntered('another horse', 'another horse');
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
cubit.handleEntered('anna');
|
||||
|
||||
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
|
||||
|
||||
|
|
@ -191,7 +240,8 @@ void main() {
|
|||
});
|
||||
|
||||
test('gives one signed in with GitHub a password with an address a code '
|
||||
'proves, the session staying theirs (ADR 0017, D2)', () async {
|
||||
'proves, the session staying theirs (ADR 0017, D2), and their first '
|
||||
'handle (ADR 0020, D28)', () async {
|
||||
await auth.switchToGitHub();
|
||||
final person = (auth.status as SignedIn).authUserId;
|
||||
final cubit = setUpAs(AccountSetupMode.setPassword)
|
||||
|
|
@ -199,6 +249,11 @@ void main() {
|
|||
await cubit.passwordEntered('correct horse', 'correct horse');
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
expect(cubit.state, at(AccountSetupStep.handle));
|
||||
// Checked as theirs: no code request names a sign-up.
|
||||
expect(await cubit.checkHandle('anna'), isNull);
|
||||
expect(gateway.calls.last, 'checkHandle');
|
||||
cubit.handleEntered('anna');
|
||||
expect(cubit.state, at(AccountSetupStep.mailCode));
|
||||
|
||||
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
|
||||
|
|
@ -208,6 +263,24 @@ void main() {
|
|||
expect(cubit.state, at(AccountSetupStep.done));
|
||||
expect(gateway.accountOf(person).finished, isTrue);
|
||||
expect((auth.status as SignedIn).authUserId, person);
|
||||
expect(gateway.handles[person], 'anna');
|
||||
});
|
||||
|
||||
test('asks no handle of a signed-in person who has one, and sends '
|
||||
'none', () async {
|
||||
await auth.switchToGitHub();
|
||||
final person = (auth.status as SignedIn).authUserId;
|
||||
gateway.handles[person] = 'anna';
|
||||
final cubit = setUpAs(AccountSetupMode.setPassword, hasHandle: true)
|
||||
..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'));
|
||||
expect(cubit.state, at(AccountSetupStep.authenticator));
|
||||
expect(gateway.handles[person], 'anna');
|
||||
});
|
||||
|
||||
test('gives a signed-in person with an address their first password only '
|
||||
|
|
@ -220,6 +293,7 @@ void main() {
|
|||
await first.passwordEntered('correct horse', 'correct horse');
|
||||
first.phraseWrittenDown();
|
||||
await first.phraseChecked(wordsAsked(first.state));
|
||||
first.handleEntered('anna');
|
||||
await first.mailCodeEntered(gateway.codeFor('anna@example.org'));
|
||||
// An address and no password, as codes by mail left accounts.
|
||||
gateway.accountOf(person)
|
||||
|
|
@ -230,6 +304,7 @@ void main() {
|
|||
AccountSetupMode.setPassword,
|
||||
address: 'anna@example.org',
|
||||
hasAddress: true,
|
||||
hasHandle: true,
|
||||
);
|
||||
expect(cubit.state, at(AccountSetupStep.password));
|
||||
await cubit.passwordEntered('another horse', 'another horse');
|
||||
|
|
@ -260,6 +335,7 @@ void main() {
|
|||
await cubit.passwordEntered('correct horse', 'correct horse');
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
cubit.handleEntered('gina');
|
||||
final code = gateway.codeFor('gina@example.org');
|
||||
|
||||
await cubit.mailCodeEntered(code);
|
||||
|
|
@ -286,6 +362,7 @@ void main() {
|
|||
await cubit.passwordEntered('another horse', 'another horse');
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
cubit.handleEntered('ben');
|
||||
|
||||
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
|
||||
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ void main() {
|
|||
address: 'ben@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('ben@example.org'),
|
||||
handle: 'ben',
|
||||
);
|
||||
|
||||
expect(pending.needsAuthenticator, isTrue);
|
||||
|
|
@ -293,10 +294,76 @@ void main() {
|
|||
address: 'anna@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('anna@example.org'),
|
||||
handle: 'anna_again',
|
||||
),
|
||||
failsWith(PasswordProblem.passwordExists),
|
||||
);
|
||||
});
|
||||
|
||||
test('checks the handle against the code mailed for the sign-up, and '
|
||||
'for nothing else (ADR 0020, D29)', () async {
|
||||
final request = await passwords.sendCode(
|
||||
'ben@example.org',
|
||||
language: 'en',
|
||||
purpose: MailCodePurpose.signUp,
|
||||
);
|
||||
gateway.handles['someone-else'] = 'anna';
|
||||
|
||||
expect(
|
||||
await passwords.checkSignUpHandle(request: request, handle: 'ben'),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await passwords.checkSignUpHandle(request: request, handle: 'Anna'),
|
||||
HandleProblem.taken,
|
||||
);
|
||||
final reset = await passwords.sendCode(
|
||||
'ben@example.org',
|
||||
language: 'en',
|
||||
purpose: MailCodePurpose.accountReset,
|
||||
);
|
||||
await expectLater(
|
||||
passwords.checkSignUpHandle(request: reset, handle: 'ben'),
|
||||
failsWith(PasswordProblem.mailCodeExpired),
|
||||
);
|
||||
expect(gateway.handlesChecked, ['ben', 'Anna']);
|
||||
});
|
||||
|
||||
test('is refused a handle taken meanwhile before the code is used, '
|
||||
'which signs up with another', () async {
|
||||
final material = await passwords.makePassword('a new password');
|
||||
final request = await passwords.sendCode(
|
||||
'ben@example.org',
|
||||
language: 'en',
|
||||
purpose: MailCodePurpose.signUp,
|
||||
);
|
||||
gateway.handles['someone-else'] = 'ben';
|
||||
final code = gateway.codeFor('ben@example.org');
|
||||
|
||||
await expectLater(
|
||||
passwords.signUp(
|
||||
material,
|
||||
address: 'ben@example.org',
|
||||
request: request,
|
||||
code: code,
|
||||
handle: 'ben',
|
||||
),
|
||||
failsWith(PasswordProblem.handleTaken),
|
||||
);
|
||||
expect(gateway.accounts, isEmpty);
|
||||
|
||||
await passwords.signUp(
|
||||
material,
|
||||
address: 'ben@example.org',
|
||||
request: request,
|
||||
code: code,
|
||||
handle: 'ben_k',
|
||||
);
|
||||
expect(
|
||||
gateway.handles[gateway.accounts['ben@example.org']!.person],
|
||||
'ben_k',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('signed in', () {
|
||||
|
|
@ -310,11 +377,23 @@ void main() {
|
|||
);
|
||||
|
||||
final material = await passwords.makePassword('her password');
|
||||
// Without the handle it lacks, before the code is used (ADR 0020,
|
||||
// D28).
|
||||
await expectLater(
|
||||
passwords.setPassword(
|
||||
material,
|
||||
address: 'dana@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('dana@example.org'),
|
||||
),
|
||||
failsWith(PasswordProblem.handleInvalid),
|
||||
);
|
||||
await passwords.setPassword(
|
||||
material,
|
||||
address: 'dana@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('dana@example.org'),
|
||||
handle: 'dana',
|
||||
);
|
||||
|
||||
expect(
|
||||
|
|
@ -323,6 +402,7 @@ void main() {
|
|||
);
|
||||
expect(keys.kept['github-person'], material.keys);
|
||||
expect(addresses.addresses['github-person'], 'dana@example.org');
|
||||
expect(gateway.handles['github-person'], 'dana');
|
||||
});
|
||||
|
||||
test('someone signed up anew gets new keys, never the keys kept on the '
|
||||
|
|
|
|||
|
|
@ -237,7 +237,8 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets('creates an account: the phrase written down and checked '
|
||||
'before the code, then the authenticator', (tester) async {
|
||||
'before the code, the handle chosen while it travels (ADR 0020, '
|
||||
'D29), then the authenticator', (tester) async {
|
||||
await start(tester);
|
||||
await tester.tap(find.text('Create an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -270,6 +271,28 @@ void main() {
|
|||
await typeCheckedWords(tester, fakePhrase);
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
expect(
|
||||
find.textContaining('A code is on its way to ben@example.org.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(field('Handle'), 'admin');
|
||||
await tester.pump();
|
||||
expect(find.text('Checking…'), findsOneWidget);
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('This handle is kept for the instance.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// A handle refused goes no further.
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(field('Code from the email'), findsNothing);
|
||||
await tester.enterText(field('Handle'), 'ben');
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@ben is free'), findsOneWidget);
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
await tester.enterText(
|
||||
field('Code from the email'),
|
||||
dependencies.passwordGateway.codeFor('ben@example.org'),
|
||||
|
|
@ -287,6 +310,41 @@ void main() {
|
|||
expect(ben.password, 'a new password');
|
||||
expect(ben.finished, isTrue);
|
||||
expect(dependencies.keys.kept.keys, [ben.person]);
|
||||
expect(dependencies.passwordGateway.handles[ben.person], 'ben');
|
||||
});
|
||||
|
||||
testWidgets('a handle taken while the code travels is asked again, the '
|
||||
'code kept', (tester) async {
|
||||
await start(tester);
|
||||
await tester.tap(find.text('Create an account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'ben@example.org');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(field('New password'), 'a new password');
|
||||
await tester.enterText(field('New password again'), 'a new password');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tapButton(tester, "I've written it down");
|
||||
await typeCheckedWords(tester, fakePhrase);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(field('Handle'), 'ben');
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
await tapButton(tester, 'Continue');
|
||||
dependencies.passwordGateway.handles['someone-else'] = 'ben';
|
||||
|
||||
final code = dependencies.passwordGateway.codeFor('ben@example.org');
|
||||
await tester.enterText(field('Code from the email'), code);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
||||
await tester.enterText(field('Handle'), 'ben_k');
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Someone else has this handle.'), findsNothing);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(field('Code from the email'), code);
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
expect(field('Code from your authenticator app'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('gets back in with the recovery phrase', (tester) async {
|
||||
|
|
@ -526,6 +584,53 @@ void main() {
|
|||
expect(find.text('Change password'), findsNothing);
|
||||
});
|
||||
|
||||
/// Sets up a password for the GitHub account signed in, up to the step
|
||||
/// after the phrase.
|
||||
Future<void> setUpPassword(WidgetTester tester) async {
|
||||
await show(tester);
|
||||
await tester.tap(find.text('Set up a password'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'gina@example.org');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(field('New password'), 'a new password');
|
||||
await tester.enterText(field('New password again'), 'a new password');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tapButton(tester, "I've written it down");
|
||||
await typeCheckedWords(tester, fakePhrase);
|
||||
await tapButton(tester, 'Continue');
|
||||
}
|
||||
|
||||
testWidgets('asks one signed in with GitHub without a handle for their '
|
||||
'first with the password (ADR 0020, D28)', (tester) async {
|
||||
await gateway.switchToGitHub();
|
||||
final person = (gateway.status as SignedIn).authUserId;
|
||||
await setUpPassword(tester);
|
||||
|
||||
await tester.enterText(field('Handle'), 'gina');
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@gina is free'), findsOneWidget);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(
|
||||
field('Code from the email'),
|
||||
dependencies.passwordGateway.codeFor('gina@example.org'),
|
||||
);
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
expect(field('Code from your authenticator app'), findsOneWidget);
|
||||
expect(dependencies.passwordGateway.handles[person], 'gina');
|
||||
});
|
||||
|
||||
testWidgets('asks no handle of one who has one', (tester) async {
|
||||
await gateway.switchToGitHub();
|
||||
final person = (gateway.status as SignedIn).authUserId;
|
||||
dependencies.passwordGateway.handles[person] = 'gina';
|
||||
await setUpPassword(tester);
|
||||
|
||||
expect(field('Handle'), findsNothing);
|
||||
expect(field('Code from the email'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('warns that a session was ended for its wrong proofs, until '
|
||||
'dismissed', (tester) async {
|
||||
final anna = dependencies.passwordGateway.addAccount(
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ void main() {
|
|||
|
||||
cubit.createChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.handle));
|
||||
expect(cubit.state.handleFree, isTrue);
|
||||
expect(await cubit.checkHandle('anna_mueller'), isNull);
|
||||
|
||||
await cubit.handleEntered(' @anna_mueller ');
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
|
|
@ -118,32 +118,33 @@ void main() {
|
|||
expect(keys.kept.keys.toList(), ['person-1']);
|
||||
});
|
||||
|
||||
test('learns on the handle that one typed is taken, and goes on with '
|
||||
'one free', () async {
|
||||
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();
|
||||
|
||||
await cubit.handleEntered('anna');
|
||||
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.handleTaken),
|
||||
problem: const SimpleLoginFailed(SimpleLoginProblem.handleInvalid),
|
||||
),
|
||||
);
|
||||
expect(cubit.state.handleFree, isFalse);
|
||||
|
||||
await cubit.handleEntered('admin');
|
||||
expect(
|
||||
cubit.state.problem,
|
||||
const SimpleLoginFailed(SimpleLoginProblem.handleReserved),
|
||||
);
|
||||
|
||||
await cubit.handleEntered('anna_m');
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
expect(cubit.state.handleFree, isTrue);
|
||||
expect(cubit.state.handle, 'anna_m');
|
||||
});
|
||||
|
||||
test('goes back to the handle when it was taken meanwhile, then on with '
|
||||
|
|
@ -265,6 +266,9 @@ void main() {
|
|||
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')));
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ final class _Instance() {
|
|||
sent(ProfileRefusal.handleTaken.toJson()),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.profile/checkHandle' => http.Response(
|
||||
sent(ProfileRefusal.handleReserved.toJson()),
|
||||
200,
|
||||
),
|
||||
_ => http.Response('', 404),
|
||||
};
|
||||
});
|
||||
|
|
@ -238,14 +242,38 @@ void main() {
|
|||
'answers', () async {
|
||||
expect(
|
||||
await gateway.checkHandle(_Instance.attempt, 'anna'),
|
||||
SimpleLoginProblem.handleTaken,
|
||||
HandleProblem.taken,
|
||||
);
|
||||
|
||||
instance.refusals['halkyon_base.simpleLogin/checkHandle'] =
|
||||
ProfileRefusedException(reason: ProfileRefusal.tooManyHandleChecks);
|
||||
expect(
|
||||
await gateway.checkHandle(_Instance.attempt, 'anna'),
|
||||
SimpleLoginProblem.tooManyHandleChecks,
|
||||
HandleProblem.tooManyChecks,
|
||||
);
|
||||
});
|
||||
|
||||
test("checks an account's first handle on its own session, not yet the "
|
||||
"device's (ADR 0020, D28)", () async {
|
||||
final grant = SessionGrant(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
AuthSuccess(
|
||||
authStrategy: 'session',
|
||||
token: 'anna-token',
|
||||
authUserId: UuidValue.withValidation(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
),
|
||||
scopeNames: {'identified'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await gateway.checkAccountHandle('admin', as: grant),
|
||||
HandleProblem.reserved,
|
||||
);
|
||||
expect(
|
||||
sessionOf('halkyon_base.profile/checkHandle'),
|
||||
contains('anna-token'),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -209,15 +209,19 @@ void main() {
|
|||
expect(await simpleLogin.checkHandle(newcomer, 'anna_mueller'), isNull);
|
||||
expect(
|
||||
await simpleLogin.checkHandle(newcomer, 'taken_one'),
|
||||
SimpleLoginProblem.handleTaken,
|
||||
HandleProblem.taken,
|
||||
);
|
||||
expect(
|
||||
await simpleLogin.checkHandle(newcomer, 'admin'),
|
||||
SimpleLoginProblem.handleReserved,
|
||||
HandleProblem.reserved,
|
||||
);
|
||||
expect(
|
||||
await simpleLogin.checkHandle(newcomer, 'not a handle'),
|
||||
SimpleLoginProblem.handleInvalid,
|
||||
HandleProblem.invalid,
|
||||
);
|
||||
expect(
|
||||
gateway.calls.where((call) => call == 'checkHandle'),
|
||||
hasLength(4),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -307,6 +311,13 @@ void main() {
|
|||
test('makes new ones, with the handle it lacks', () async {
|
||||
expect(account.hasKeys, isFalse);
|
||||
expect(account.handle, isNull);
|
||||
// Checked as the account's: no attempt waits for it any more.
|
||||
gateway.addAccount('person-8', subject: 'other', handle: 'ann');
|
||||
expect(
|
||||
await simpleLogin.checkHandle(account, 'ann'),
|
||||
HandleProblem.taken,
|
||||
);
|
||||
expect(gateway.calls.last, 'checkAccountHandle');
|
||||
|
||||
final made = await simpleLogin.makeKeys(password, into: account);
|
||||
final pending = await simpleLogin.setFirstKeys(
|
||||
|
|
|
|||
|
|
@ -107,7 +107,11 @@ void main() {
|
|||
await tester.enterText(field('Handle'), 'anna_m');
|
||||
await tester.pump();
|
||||
expect(find.text('@anna_mueller is free'), findsNothing);
|
||||
expect(find.text('Checking…'), findsOneWidget);
|
||||
await tester.enterText(field('Handle'), 'anna_mueller');
|
||||
await tester.pump();
|
||||
// The suggestion is free without asking (ADR 0020, D30).
|
||||
expect(find.text('@anna_mueller is free'), findsOneWidget);
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
await tester.enterText(field('New password'), password);
|
||||
|
|
@ -122,6 +126,34 @@ void main() {
|
|||
expect(find.text('App of person-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('someone new is told a handle is taken as they type it, and '
|
||||
'goes no further with it', (tester) async {
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-other',
|
||||
handle: 'anna',
|
||||
keys: (await dependencies.crypto.newKeys(password)).setup,
|
||||
);
|
||||
await start(tester);
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Create an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(field('Handle'), '@anna');
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(field('New password'), findsNothing);
|
||||
|
||||
await tester.enterText(field('Handle'), 'anna_k');
|
||||
await tester.pump(HandleCheckCubit.pause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@anna_k is free'), findsOneWidget);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(field('New password'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an account opens its keys with its password, a wrong one '
|
||||
'refused', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
|
|
|
|||
Loading…
Reference in a new issue