382 lines
12 KiB
Dart
382 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();
|
||
|
|
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(cubit.state.handleFree, isTrue);
|
||
|
|
|
||
|
|
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('learns on the handle that one typed is taken, and goes on with '
|
||
|
|
'one free', () async {
|
||
|
|
gateway.addAccount('person-9', subject: 'other', handle: 'anna');
|
||
|
|
final cubit = cubitFor();
|
||
|
|
await cubit.started();
|
||
|
|
cubit.createChosen();
|
||
|
|
|
||
|
|
await cubit.handleEntered('anna');
|
||
|
|
expect(
|
||
|
|
cubit.state,
|
||
|
|
at(
|
||
|
|
SimpleLoginStep.handle,
|
||
|
|
problem: const SimpleLoginFailed(SimpleLoginProblem.handleTaken),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
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));
|
||
|
|
|
||
|
|
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('an anonymous person keeps their device keys, and is identified '
|
||
|
|
'once the keys are set', () async {
|
||
|
|
await auth.signInAnonymously();
|
||
|
|
final deviceKeys = crypto.keysNumbered(1);
|
||
|
|
keys.kept['anonymous-1'] = 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: 'anonymous-1', isIdentified: true),
|
||
|
|
);
|
||
|
|
expect(keys.kept, {'anonymous-1': 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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|