import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:cryptography/cryptography.dart'; import 'package:halkyon_base_server/base.dart'; import 'package:halkyon_base_server/src/password/passwords.dart' show passwordRowOf; import 'package:halkyon_oidc_server/oidc.dart'; import 'package:halkyon_oidc_server/testing.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; import 'package:test/test.dart'; import '../support/fixtures.dart'; import '../support/keys_device.dart'; import '../support/mailbox.dart'; import '../support/password_device.dart'; import '../support/password_support.dart'; import '../test_tools/serverpod_test_tools.dart'; // The keys of an account that signs in with SimpleLogin on a signed-in // device (Halkyon Learn's ADR 0020, 7, D19 and D20): the password proven // with its own proof key, a signature over the register's nonce, before the // device gets the keys wrapped under it; the changes, each with that proof // (the phrase's for a lost password) and a SimpleLogin sign-in of the last // ten minutes, against a fake SimpleLogin (halkyon_oidc's testing.dart). // Every way a proof is refused, and a session's wrong proofs ending it as // ADR 0017's do. void main() { late TestKey key; setUpAll(() => key = TestKey.generate('sl', seed: 13)); withServerpod( 'The keys of an account that signs in with SimpleLogin', // Sessions ended for wrong proofs, in transactions of their own: these // tests delete what they make instead. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late KeysDevice device; late FakeProvider simpleLogin; /// With keys, signed in on her phone and her laptop; SimpleLogin's /// `sl-anna`. late UuidValue anna; late UuidValue annasPhone; late UuidValue annasLaptop; /// Without keys, signed in on his laptop; SimpleLogin's `sl-ben`. late UuidValue ben; late UuidValue bensLaptop; setUp(() async { useBase(sessionBuilder, app: 'learn'); session = sessionBuilder.build(); await _deleteAll(session); simpleLogin = FakeProvider.simpleLogin( key: key, clientId: 'halkyon-learn', clientSecret: 'a secret of the app', callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'), ); useSimpleLogin(sessionBuilder, simpleLogin); device = await KeysDevice.make(); anna = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await giveKeys(session, anna, device); annasPhone = await signInNewDevice( session, anna, method: 'simplelogin', ); annasLaptop = await signInNewDevice( session, anna, method: 'simplelogin', ); ben = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; bensLaptop = await signInNewDevice(session, ben, method: 'simplelogin'); for (final (person, subject) in [(anna, 'sl-anna'), (ben, 'sl-ben')]) { await OidcIdentity.db.insertRow( session, OidcIdentity( authUserId: person, provider: simpleLoginProvider, subject: subject, ), ); } }); tearDown(() async { useSimpleLoginApp(session.serverpod, null); await _deleteAll(sessionBuilder.build()); }); TestSessionBuilder signedIn(UuidValue person, UuidValue on) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( person.uuid, {identified}, authId: on.uuid, ), ); TestSessionBuilder onPhone() => signedIn(anna, annasPhone); OidcIdp idp() => AuthServices.instance.oidcIdp; Matcher refusedAsOidc(OidcRefusal reason) => throwsA( isA().having((e) => e.reason, 'reason', reason), ); Matcher refusedAsProfile(ProfileRefusal reason) => throwsA( isA().having( (e) => e.reason, 'reason', reason, ), ); /// A SimpleLogin sign-in on [as], as [subject], just now: its attempt, /// which stands for it ten minutes (D20). Future signInAgain( TestSessionBuilder as, String subject, ) async { final start = await endpoints.simpleLogin.begin( as, back: SimpleLoginReturn.loopback, ); await endpoints.simpleLogin.complete( as, attempt: start.attempt, code: simpleLogin.authorize(start.authorizationUrl, subject: subject), ); return start.attempt; } Future> sessionsOf(UuidValue person) async => { for (final row in await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(person), )) row.id!, }; group('unlocking them', () { test('gives the salt and costs, then the keys once the password ' 'proved right', () async { final challenge = await endpoints.keys.begin(onPhone()); expect(Uint8List.sublistView(challenge.salt), device.salt); expect(challenge.costs.toJson(), newPasswordCosts.toJson()); expect(challenge.nonce.lengthInBytes, 32); final keys = await endpoints.keys.unlock( onPhone(), await device.prove(challenge), ); expect( Uint8List.sublistView(keys.signingPublicKey), device.signingPublicKey, ); expect( Uint8List.sublistView(keys.encryptionPublicKey), device.encryptionPublicKey, ); expect(keys.passwordKeys.toJson(), device.passwordKeys.toJson()); }); test('refuses a signature by another key: the recovery phrase, or ' "another password's", () async { for (final keys in [ device.recoveryProofKeys, (await KeysDevice.make(seed: 5)).passwordProofKeys, ]) { final challenge = await endpoints.keys.begin(onPhone()); await expectLater( endpoints.keys.unlock( onPhone(), await device.prove(challenge, keys: keys), ), refused(PasswordRefusal.wrongCredentials), ); } }); test('refuses a proof made for another purpose or change', () async { for (final (purpose, digest) in [ (KeyProofPurpose.deleteAccount, const []), (KeyProofPurpose.unlockKeys, const [1]), ]) { final challenge = await endpoints.keys.begin(onPhone()); await expectLater( endpoints.keys.unlock( onPhone(), await device.prove(challenge, purpose: purpose, digest: digest), ), refused(PasswordRefusal.wrongCredentials), ); } }); test('refuses a proof made for another instance, which a hostile ' 'one could relay here', () async { final challenge = await endpoints.keys.begin(onPhone()); await expectLater( endpoints.keys.unlock( onPhone(), await device.prove( challenge, instanceUrl: 'https://evil.example/', claimedUrl: testInstanceUrl, ), ), refused(PasswordRefusal.wrongCredentials), ); await expectLater( endpoints.keys.unlock( onPhone(), await device.prove( await endpoints.keys.begin(onPhone()), instanceUrl: 'https://evil.example/', ), ), refused(PasswordRefusal.otherInstance), ); }); test('takes each challenge once, and only from whom it was begun ' 'for', () async { final challenge = await endpoints.keys.begin(onPhone()); await endpoints.keys.unlock( onPhone(), await device.prove(challenge), ); await expectLater( endpoints.keys.unlock(onPhone(), await device.prove(challenge)), refused(PasswordRefusal.challengeExpired), ); final annas = await endpoints.keys.begin(onPhone()); await giveKeys(session, ben, await KeysDevice.make(seed: 3)); await expectLater( endpoints.keys.unlock( signedIn(ben, bensLaptop), await device.prove(annas), ), refused(PasswordRefusal.challengeExpired), ); }); test('refuses an account without such keys: none, or a password ' 'that signs in (ADR 0017)', () async { await expectLater( endpoints.keys.begin(signedIn(ben, bensLaptop)), refused(PasswordRefusal.noPassword), ); await AccountPassword.db.insertRow( session, passwordRowOf( ben, (await PasswordDevice.make()).setup(), now: DateTime.now().toUtc(), ), ); await expectLater( endpoints.keys.begin(signedIn(ben, bensLaptop)), refused(PasswordRefusal.noPassword), ); }); test('takes no session at all', () async { final notSignedIn = sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); await expectLater( endpoints.keys.begin(notSignedIn), throwsA(isA()), ); }); test("a session's wrong proofs count for it alone: the tenth in a " "row ends it, the owner's other session unlocking as before, and " 'the owner is warned', () async { final other = await KeysDevice.make(seed: 5); for (var i = 0; i < 9; i++) { await expectLater( endpoints.keys.unlock( onPhone(), await other.prove(await endpoints.keys.begin(onPhone())), ), refused(PasswordRefusal.wrongCredentials), ); } await expectLater( endpoints.keys.unlock( onPhone(), await other.prove(await endpoints.keys.begin(onPhone())), ), refused(PasswordRefusal.sessionEnded), ); expect(await sessionsOf(anna), {annasLaptop}); expect( (await endpoints.account.signInWays( signedIn(anna, annasLaptop), )).sessionEndedForProofsAt, isNotNull, ); final laptop = signedIn(anna, annasLaptop); await endpoints.keys.unlock( laptop, await device.prove(await endpoints.keys.begin(laptop)), ); }); test('a right proof clears the wrong ones before it', () async { final other = await KeysDevice.make(seed: 5); for (var round = 0; round < 2; round++) { for (var i = 0; i < 9; i++) { await expectLater( endpoints.keys.unlock( onPhone(), await other.prove(await endpoints.keys.begin(onPhone())), ), refused(PasswordRefusal.wrongCredentials), ); } await endpoints.keys.unlock( onPhone(), await device.prove(await endpoints.keys.begin(onPhone())), ); } expect(await sessionsOf(anna), {annasPhone, annasLaptop}); }); }); group('first keys, for an account SimpleLogin was added to (D20)', () { late KeysDevice bens; setUp(() async => bens = await KeysDevice.make(seed: 3)); TestSessionBuilder onBensLaptop() => signedIn(ben, bensLaptop); Future setWith( UuidValue attempt, { KeysSetup? setup, String? handle = 'ben', }) => endpoints.keys.set( onBensLaptop(), attempt: attempt, setup: setup ?? bens.setup(), handle: handle, ); test('are set with a SimpleLogin sign-in of the last ten minutes, ' 'taken, with a handle for an account without one, and then ' 'unlock', () async { final attempt = await signInAgain(onBensLaptop(), 'sl-ben'); await setWith(attempt, handle: 'Ben'); final row = (await AccountPassword.db.findFirstRow( session, where: (t) => t.authUserId.equals(ben), ))!; expect(row.verifier, isNull); expect( Uint8List.sublistView(row.passwordProofKey!), bens.passwordProofKey, ); expect( (await PublicProfile.db.findFirstRow( session, where: (t) => t.authUserId.equals(ben), ))!.handle, 'Ben', ); expect( (await endpoints.account.signInWays(onBensLaptop())).keys, isTrue, ); expect( await idp().isKept(session, attempt: attempt, caller: ben), isFalse, ); await endpoints.keys.unlock( onBensLaptop(), await bens.prove(await endpoints.keys.begin(onBensLaptop())), ); }); test('keep the handle an account has, refusing another', () async { await AccountRegister.of(session.serverpod).saveProfile( session, ben, handle: 'Ben', displayName: '', bio: '', links: const [], ); final attempt = await signInAgain(onBensLaptop(), 'sl-ben'); await expectLater( setWith(attempt, handle: 'Benjamin'), refusedAsProfile(ProfileRefusal.handleInvalid), ); await setWith(attempt, handle: null); expect( (await PublicProfile.db.findFirstRow( session, where: (t) => t.authUserId.equals(ben), ))!.handle, 'Ben', ); }); test( 'refuse a handle missing or refused as saving a profile refuses ' 'it, and a setup out of range, the sign-in still standing', () async { await AccountRegister.of(session.serverpod).saveProfile( session, anna, handle: 'Anna', displayName: '', bio: '', links: const [], ); final attempt = await signInAgain(onBensLaptop(), 'sl-ben'); for (final (handle, reason) in [ (null, ProfileRefusal.handleInvalid), ('ANNA', ProfileRefusal.handleTaken), ('admin', ProfileRefusal.handleReserved), ('ben!', ProfileRefusal.handleInvalid), ]) { await expectLater( setWith(attempt, handle: handle), refusedAsProfile(reason), reason: '$handle', ); } await expectLater( setWith( attempt, setup: bens.setup().copyWith(salt: ByteData(15)), ), refused(PasswordRefusal.invalidSetup), ); await setWith(attempt); }, ); test("count a new handle among the person's answers about handles " '(30 an hour), the sign-in still standing when refused', () async { final attempt = await signInAgain(onBensLaptop(), 'sl-ben'); for (var i = 0; i < handleChecksPerAsker; i++) { await countHandleCheck(session, asker: ben.uuid, network: null); } await expectLater( setWith(attempt), refusedAsProfile(ProfileRefusal.tooManyHandleChecks), ); expect( await idp().isKept(session, attempt: attempt, caller: ben), isTrue, ); }); test('refuse an account with keys already, under either kind of ' 'password', () async { final annas = await signInAgain(onPhone(), 'sl-anna'); await expectLater( endpoints.keys.set(onPhone(), attempt: annas, setup: bens.setup()), refused(PasswordRefusal.passwordExists), ); await AccountPassword.db.insertRow( session, passwordRowOf( ben, (await PasswordDevice.make()).setup(), now: DateTime.now().toUtc(), ), ); await expectLater( setWith(await signInAgain(onBensLaptop(), 'sl-ben')), refused(PasswordRefusal.passwordExists), ); }); test("refuse without a SimpleLogin sign-in of the account's: none, " "past its ten minutes, or another account's, which stands for " 'it still', () async { final annas = await signInAgain(onPhone(), 'sl-anna'); final attempt = await signInAgain(onBensLaptop(), 'sl-ben'); await expectLater( setWith(const Uuid().v4obj()), refusedAsOidc(OidcRefusal.unknownAttempt), ); await expectLater( withClock( Clock.fixed(DateTime.now().add(const Duration(minutes: 11))), () => setWith(attempt), ), refusedAsOidc(OidcRefusal.unknownAttempt), ); await expectLater( setWith(annas), refusedAsOidc(OidcRefusal.wrongCaller), ); expect( await idp().isKept(session, attempt: annas, caller: anna), isTrue, ); await setWith(attempt); }); }); group('a new password (D20)', () { late KeysDevice next; setUp(() async => next = await KeysDevice.make(seed: 7)); /// The proof for a new password ([binding], else [next]'s) by /// [keys] (the password's proof key unless given), with the /// sign-in under [attempt]. Future changeProof( UuidValue attempt, { KeysPassword? binding, SimpleKeyPair? keys, KeyProofPurpose purpose = KeyProofPurpose.newPassword, KeysChallenge? challenge, }) async => await device.ownerProof( attempt, challenge: challenge ?? await endpoints.keys.begin(onPhone()), purpose: purpose, digest: newKeysPasswordDigest(binding ?? next.password()), keys: keys, ); Future changeWith(KeysOwnerProof proof, {KeysPassword? to}) => endpoints.keys.changePassword( onPhone(), proof: proof, password: to ?? next.password(), ); test("takes the old password's proof and a SimpleLogin sign-in: the " 'keys open with the new one alone, and every other session ' 'ends', () async { final attempt = await signInAgain(onPhone(), 'sl-anna'); await changeWith(await changeProof(attempt)); expect(await sessionsOf(anna), {annasPhone}); final challenge = await endpoints.keys.begin(onPhone()); expect(Uint8List.sublistView(challenge.salt), next.salt); final keys = await endpoints.keys.unlock( onPhone(), await next.prove(challenge), ); expect(keys.passwordKeys.toJson(), next.passwordKeys.toJson()); await expectLater( endpoints.keys.unlock( onPhone(), await device.prove(await endpoints.keys.begin(onPhone())), ), refused(PasswordRefusal.wrongCredentials), ); }); test('refuses a proof by another key, for another password or ' 'purpose, and a password out of range, the sign-in still ' 'standing', () async { final attempt = await signInAgain(onPhone(), 'sl-anna'); for (final proof in [ await changeProof(attempt, keys: device.recoveryProofKeys), await changeProof( attempt, binding: (await KeysDevice.make(seed: 9)).password(), ), await changeProof(attempt, purpose: KeyProofPurpose.newPhrase), ]) { await expectLater( changeWith(proof), refused(PasswordRefusal.wrongCredentials), ); } await expectLater( changeWith( await changeProof(attempt), to: next.password().copyWith(passwordProofKey: ByteData(31)), ), refused(PasswordRefusal.invalidSetup), ); expect(await sessionsOf(anna), {annasPhone, annasLaptop}); await changeWith(await changeProof(attempt)); }); test('refuses without a SimpleLogin sign-in of hers, checking no ' "proof: none, another account's, past its ten minutes, or one a " 'change took', () async { final bens = await signInAgain(signedIn(ben, bensLaptop), 'sl-ben'); final challenge = await endpoints.keys.begin(onPhone()); for (final attempt in [const Uuid().v4obj(), bens]) { await expectLater( changeWith(await changeProof(attempt, challenge: challenge)), refusedAsOidc(OidcRefusal.unknownAttempt), ); } final annas = await signInAgain(onPhone(), 'sl-anna'); await expectLater( withClock( Clock.fixed(DateTime.now().add(const Duration(minutes: 11))), () async => await changeWith( await changeProof(annas, challenge: challenge), ), ), refusedAsOidc(OidcRefusal.unknownAttempt), ); // The challenge still answers: no proof was taken. await changeWith(await changeProof(annas, challenge: challenge)); // One change for each sign-in. await expectLater( changeWith( await next.ownerProof( annas, challenge: await endpoints.keys.begin(onPhone()), purpose: KeyProofPurpose.newPassword, digest: newKeysPasswordDigest(device.password()), ), to: device.password(), ), refusedAsOidc(OidcRefusal.unknownAttempt), ); }); test("counts the session's wrong proofs, as unlocking does: the " 'tenth in a row ends it', () async { final attempt = await signInAgain(onPhone(), 'sl-anna'); for (var i = 0; i < 10; i++) { await expectLater( changeWith( await changeProof(attempt, keys: device.recoveryProofKeys), ), refused( i < 9 ? PasswordRefusal.wrongCredentials : PasswordRefusal.sessionEnded, ), ); } expect(await sessionsOf(anna), {annasLaptop}); }); }); group('a lost password (D20)', () { late KeysDevice next; setUp(() async => next = await KeysDevice.make(seed: 7)); Future resetWith(KeysOwnerProof proof) => endpoints.keys .resetPassword(onPhone(), proof: proof, password: next.password()); test("is set anew with the recovery phrase's proof and a SimpleLogin " 'sign-in, every other session ending', () async { final phrase = await endpoints.keys.beginPhrase(onPhone()); expect(phrase.recoveryKeys.toJson(), device.recoveryKeys.toJson()); expect( Uint8List.sublistView(phrase.signingPublicKey), device.signingPublicKey, ); expect(phrase.nonce.lengthInBytes, 32); final attempt = await signInAgain(onPhone(), 'sl-anna'); await resetWith( await device.ownerProof( attempt, phrase: phrase, purpose: KeyProofPurpose.passwordReset, digest: newKeysPasswordDigest(next.password()), ), ); expect(await sessionsOf(anna), {annasPhone}); await endpoints.keys.unlock( onPhone(), await next.prove(await endpoints.keys.begin(onPhone())), ); }); test("refuses the password's proof key, a challenge of keys.begin, " 'and a proof for another purpose', () async { final attempt = await signInAgain(onPhone(), 'sl-anna'); final digest = newKeysPasswordDigest(next.password()); await expectLater( resetWith( await device.ownerProof( attempt, phrase: await endpoints.keys.beginPhrase(onPhone()), keys: device.passwordProofKeys, purpose: KeyProofPurpose.passwordReset, digest: digest, ), ), refused(PasswordRefusal.wrongCredentials), ); await expectLater( resetWith( await device.ownerProof( attempt, challenge: await endpoints.keys.begin(onPhone()), keys: device.recoveryProofKeys, purpose: KeyProofPurpose.passwordReset, digest: digest, ), ), refused(PasswordRefusal.challengeExpired), ); await expectLater( resetWith( await device.ownerProof( attempt, phrase: await endpoints.keys.beginPhrase(onPhone()), purpose: KeyProofPurpose.newPassword, digest: digest, ), ), refused(PasswordRefusal.wrongCredentials), ); await resetWith( await device.ownerProof( attempt, phrase: await endpoints.keys.beginPhrase(onPhone()), purpose: KeyProofPurpose.passwordReset, digest: digest, ), ); }); test('is for an account with such keys alone', () async { final bens = signedIn(ben, bensLaptop); await expectLater( endpoints.keys.beginPhrase(bens), refused(PasswordRefusal.noPassword), ); await AccountPassword.db.insertRow( session, passwordRowOf( ben, (await PasswordDevice.make()).setup(), now: DateTime.now().toUtc(), ), ); await expectLater( endpoints.keys.beginPhrase(bens), refused(PasswordRefusal.noPassword), ); }); }); group('a new recovery phrase (D20)', () { late KeysDevice next; setUp(() async => next = await KeysDevice.make(seed: 7)); Future setPhrase( KeysOwnerProof proof, { WrappedKeys? recoveryKeys, }) => endpoints.keys.setRecoveryKeys( onPhone(), proof: proof, recoveryKeys: recoveryKeys ?? next.recoveryKeys, recoveryProofKey: ByteData.sublistView(next.recoveryProofKey), ); Future phraseProof(UuidValue attempt) async => await device.ownerProof( attempt, challenge: await endpoints.keys.begin(onPhone()), purpose: KeyProofPurpose.newPhrase, digest: newPhraseDigest( next.recoveryKeys, ByteData.sublistView(next.recoveryProofKey), ), ); test("takes the password's proof and a SimpleLogin sign-in, the old " 'phrase proving nothing then, the sessions staying', () async { await setPhrase( await phraseProof(await signInAgain(onPhone(), 'sl-anna')), ); final phrase = await endpoints.keys.beginPhrase(onPhone()); expect(phrase.recoveryKeys.toJson(), next.recoveryKeys.toJson()); expect(await sessionsOf(anna), {annasPhone, annasLaptop}); final again = await signInAgain(onPhone(), 'sl-anna'); final digest = newKeysPasswordDigest(next.password()); await expectLater( endpoints.keys.resetPassword( onPhone(), proof: await device.ownerProof( again, phrase: phrase, purpose: KeyProofPurpose.passwordReset, digest: digest, ), password: next.password(), ), refused(PasswordRefusal.wrongCredentials), ); await endpoints.keys.resetPassword( onPhone(), proof: await next.ownerProof( again, phrase: await endpoints.keys.beginPhrase(onPhone()), purpose: KeyProofPurpose.passwordReset, digest: digest, ), password: next.password(), ); }); test('refuses a phrase out of range, or a proof for another ' 'one', () async { final attempt = await signInAgain(onPhone(), 'sl-anna'); await expectLater( setPhrase( await phraseProof(attempt), recoveryKeys: next.recoveryKeys.copyWith(nonce: ByteData(12)), ), refused(PasswordRefusal.invalidSetup), ); await expectLater( setPhrase( await phraseProof(attempt), recoveryKeys: device.recoveryKeys, ), refused(PasswordRefusal.wrongCredentials), ); await setPhrase(await phraseProof(attempt)); }); }); group('deleting the account (D20)', () { Future deletionProof( UuidValue attempt, { KeyProofPurpose purpose = KeyProofPurpose.deleteAccount, }) async => await device.ownerProof( attempt, challenge: await endpoints.keys.begin(onPhone()), purpose: purpose, ); test("takes the password's proof and a SimpleLogin sign-in", () async { await endpoints.account.delete( onPhone(), keysProof: await deletionProof( await signInAgain(onPhone(), 'sl-anna'), ), ); expect(await AuthUser.db.findById(session, anna), isNull); }); test('refuses a session alone, the proof of a password that signs ' 'in, a proof for another purpose, and one without a sign-in, ' 'keeping the account', () async { await expectLater( endpoints.account.delete(onPhone()), refused(PasswordRefusal.wrongCredentials), ); await expectLater( endpoints.account.delete( onPhone(), proof: DeletionProof( challenge: const Uuid().v4obj(), clientValue: ByteData(384), clientProof: ByteData(32), totpCode: '000000', instanceUrl: testInstanceUrl, ), ), refused(PasswordRefusal.wrongCredentials), ); await expectLater( endpoints.account.delete( onPhone(), keysProof: await deletionProof(const Uuid().v4obj()), ), refusedAsOidc(OidcRefusal.unknownAttempt), ); await expectLater( endpoints.account.delete( onPhone(), keysProof: await deletionProof( await signInAgain(onPhone(), 'sl-anna'), purpose: KeyProofPurpose.newPassword, ), ), refused(PasswordRefusal.wrongCredentials), ); expect(await AuthUser.db.findById(session, anna), isNotNull); }); }); group('the ways in', () { test('say the account has keys, and no password that signs in, nor ' 'an authenticator to add', () async { // Where password sign-in is offered, which takes mail too. final pod = session.serverpod; usePasswordKeys(pod, testPasswordKeys); EmailCodes.of(pod).mailer = Mailbox(); addTearDown(() => EmailCodes.of(pod).mailer = null); final ways = await endpoints.account.signInWays(onPhone()); expect(ways.keys, isTrue); expect(ways.password, isFalse); expect(ways.passwordOnly, isFalse); await expectLater( endpoints.authenticator.enrol( onPhone(), name: 'Phone', label: 'anna', ), refused(PasswordRefusal.noPassword), ); }); }); }, ); } Future _deleteAll(Session session) async { await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null)); await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0); await deleteAll(session); }