import 'dart:io'; import 'package:clock/clock.dart'; import 'package:halkyon_base_server/base.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; import 'package:test/test.dart'; import '../support/fixtures.dart'; import '../test_tools/serverpod_test_tools.dart'; /// Keeps every code mailed, in place of a mail service. final class _Mailbox implements CodeMailer { final sent = <({String to, String code, String language})>[]; @override Future send( Session session, { required String to, required String code, required String language, }) async { sent.add((to: to, code: code, language: language)); } @override Future sendResetNotice( Session session, { required String to, required AccountResetKind kind, required DateTime takesEffectAt, required String language, }) async {} /// The last code mailed to [address]. String codeFor(String address) => sent.lastWhere((mail) => mail.to == address).code; } /// A mail service that takes no mail. final class _Refusing implements CodeMailer { @override Future send( Session session, { required String to, required String code, required String language, }) async => throw const SocketException('Connection refused'); @override Future sendResetNotice( Session session, { required String to, required AccountResetKind kind, required DateTime takesEffectAt, required String language, }) async => throw const SocketException('Connection refused'); } void main() { withServerpod( 'Codes by mail', // Mailing and accepting codes count attempts in transactions of their // own (Serverpod's rate limiter), which the test's database rollback // can't wrap. So these tests delete what they make instead. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late _Mailbox mailbox; /// Anonymous, signed in on her phone. late UuidValue anna; late UuidValue annasPhone; /// Identified, signing in with ben@example.org. late UuidValue ben; /// Identified, signing in with GitHub. late UuidValue carla; setUp(() async { useBase(sessionBuilder); session = sessionBuilder.build(); await _deleteAll(session); mailbox = _Mailbox(); EmailCodes.of(session.serverpod).mailer = mailbox; anna = (await AuthServices.instance.authUsers.create(session)).id; await AnonymousAccount.db.insertRow( session, AnonymousAccount(authUserId: anna), ); annasPhone = await signInNewDevice(session, anna); ben = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await EmailIdentity.db.insertRow( session, EmailIdentity( authUserId: ben, addressHash: addressHash(session.serverpod, 'ben@example.org'), createdAt: clock.now(), ), ); await signInNewDevice( session, ben, scopes: {identified}, ); carla = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await GitHubAccount.db.insertRow( session, GitHubAccount(userIdentifier: '1001', authUserId: carla), ); await signInNewDevice( session, carla, scopes: {identified}, ); }); tearDown(() async { EmailCodes.of(session.serverpod).mailer = null; await _deleteAll(sessionBuilder.build()); }); TestSessionBuilder signedIn( UuidValue person, UuidValue sessionId, { bool isIdentified = false, }) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( person.uuid, {if (isIdentified) identified}, authId: sessionId.uuid, ), ); TestSessionBuilder asAnna() => signedIn(anna, annasPhone); TestSessionBuilder notSignedIn() => sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); /// Mails a code to [address] for [as] and types it. group('refuses', () { test('nothing that turns a code into a session: a code signs nobody ' "in, and makes no address anyone's", () async { final request = await endpoints.emailCode.sendCode( notSignedIn(), address: 'anna@example.org', language: 'en', ); expect( await proveAddressCode( session, request: request, code: mailbox.codeFor('anna@example.org'), ), addressHash(session.serverpod, 'anna@example.org'), ); expect(await _addressesOf(session, anna), isEmpty); expect(await ServerSideSession.db.count(session), 3); expect(await EmailCodeRequest.db.count(session), 0); }); test( 'everything on an instance that sends no mail, and says so', () async { EmailCodes.of(session.serverpod).mailer = null; await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ), throwsA(_refusal(EmailSignInRefusal.unavailable)), ); expect( (await endpoints.instance.basics(notSignedIn())).emailSignIn, isFalse, ); }, ); test('a code it could not mail, keeping nothing of it', () async { EmailCodes.of(session.serverpod).mailer = _Refusing(); await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ), throwsA(_refusal(EmailSignInRefusal.mailFailed)), ); expect(await EmailCodeRequest.db.count(session), 0); expect(await SecretChallenge.db.count(session), 0); }); test('what is no address', () async { for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) { await expectLater( endpoints.emailCode.sendCode( asAnna(), address: typed, language: 'en', ), throwsA(_refusal(EmailSignInRefusal.notAnAddress)), reason: typed, ); } expect(mailbox.sent, isEmpty); }); test('a wrong code, and after three wrong ones even the right one; ' 'nothing changes', () async { final request = await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); for (var i = 0; i < 3; i++) { await expectLater( proveAddressCode( session, request: request, code: 'AAAA-AAAA', ), throwsA(_refusal(EmailSignInRefusal.wrongCode)), ); } await expectLater( proveAddressCode( session, request: request, code: mailbox.codeFor('anna@example.org'), ), throwsA(_refusal(EmailSignInRefusal.tooManyTries)), ); expect(await _addressesOf(session, anna), isEmpty); }); test('a code past its 15 minutes, a code used, and the oldest of ' 'four: up to three are valid at once, so codes others ask for ' "don't void the one the owner is typing", () async { final late = await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); final lateCode = mailbox.codeFor('anna@example.org'); await withClock( Clock.fixed(clock.now().add(const Duration(minutes: 16))), () => expectLater( proveAddressCode( session, request: late, code: lateCode, ), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ), ); Future<(UuidValue, String)> ask(String network) async { final request = await sendSignInCode( session, address: 'anna@example.org', language: 'en', network: network, ); return (request, mailbox.codeFor('anna@example.org')); } final (owners, ownersCode) = await ask('203.0.113.1'); final (second, secondCode) = await ask('198.51.100.7'); final (third, thirdCode) = await ask('198.51.100.7'); // Three valid: the owner's still is. await proveAddressCode(session, request: owners, code: ownersCode); final (fourth, fourthCode) = await ask('198.51.100.7'); final (fifth, _) = await ask('192.0.2.9'); // Five asked for, three valid: the two oldest left went. await expectLater( proveAddressCode(session, request: second, code: secondCode), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ); await proveAddressCode(session, request: third, code: thirdCode); await proveAddressCode(session, request: fourth, code: fourthCode); await expectLater( proveAddressCode(session, request: fourth, code: fourthCode), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ); expect(fifth, isNotNull); }); test('a fourth code for one address from one network within an ' 'hour, an eleventh for one address from any, and an eleventh ' 'from one network', () async { Future ask(String address, String network) => sendSignInCode( session, address: address, language: 'en', network: network, ); for (var i = 0; i < 3; i++) { await ask('anna@example.org', '203.0.113.1'); } await expectLater( ask('Anna@Example.org', '203.0.113.1'), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), ); // Another network still gets one: whoever filled the first count // holds back no other, the owner's among them. for (var i = 0; i < 7; i++) { await ask('anna@example.org', '198.51.100.${i ~/ 3}'); } // Ten mailed to one address in the hour: no more, from anywhere. await expectLater( ask('anna@example.org', '192.0.2.200'), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), ); for (var i = 0; i < 10; i++) { await ask('anna$i@example.org', '192.0.2.9'); } await expectLater( ask('anna.else@example.org', '192.0.2.9'), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), ); expect(mailbox.sent, hasLength(20)); }); test('saying how someone signs in when not signed in', () async { await expectLater( endpoints.account.signInWays(notSignedIn()), throwsA(isA()), ); }); }); test('mails a code in the language asked for, the same whether anybody ' 'has the address or not', () async { final known = await endpoints.emailCode.sendCode( asAnna(), address: 'ben@example.org', language: 'de', ); final unknown = await endpoints.emailCode.sendCode( asAnna(), address: ' Nobody@Example.org ', language: 'fr', ); expect(known, isNot(unknown)); expect( [for (final mail in mailbox.sent) (mail.to, mail.language)], [('ben@example.org', 'de'), ('nobody@example.org', 'en')], ); expect( mailbox.sent.map((mail) => mail.code), everyElement(matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$'))), ); }); test('tells the app that this instance offers it', () async { expect( (await endpoints.instance.basics(notSignedIn())).emailSignIn, true, ); }); test('deleting a person deletes their address', () async { await deletePerson(session, ben); expect(await EmailIdentity.db.count(session), 0); }); test('forgets the codes past their time, hourly', () async { await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); final now = clock.now(); expect(await forgetExpiredEmailCodes(session, now: now), 0); expect( await forgetExpiredEmailCodes( session, now: now.add(const Duration(minutes: 16)), ), 1, ); expect(await EmailCodeRequest.db.count(session), 0); expect(await SecretChallenge.db.count(session), 0); }); }, ); } Matcher _refusal(EmailSignInRefusal reason) => isA().having( (exception) => exception.reason, 'reason', reason, ); /// The hashes of the addresses [person] signs in with. Future> _addressesOf(Session session, UuidValue person) async => [ for (final identity in await EmailIdentity.db.find( session, where: (t) => t.authUserId.equals(person), )) identity.addressHash, ]; /// Deletes every rate-limit record, code, address and person; their /// sessions and other identities go with them (`ON DELETE CASCADE`). Future _deleteAll(Session session) async { await RateLimitedRequestAttempt.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await EmailCodeRequest.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await SecretChallenge.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await EmailIdentity.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null)); }