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() .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 wordsAsked(SimpleLoginState state) => [ for (final position in state.checked) wordsOf(state.phrase)[position], ]; /// Makes the keys with [password] and types the phrase back. Future 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(); browser.comesBack = () => waiting.future; final cubit = SimpleLoginCubit(simpleLogin); unawaited(cubit.started()); await Future.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); }); }); }