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() .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 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(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(); 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); }); }); }