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'; void main() { withServerpod('Deleting an account', (sessionBuilder, endpoints) { late Session session; late UuidValue anna; late UuidValue annasPhone; late UuidValue ben; /// What the host's deletions saw, in the order they ran. late List seen; /// The host's deletions: each notes what it finds of the person while it /// runs, and what runs once the deletion holds. List noting(String name) => [ (session, person, transaction) async { final sessions = await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(person), transaction: transaction, ); final stillThere = await AuthUser.db.findById( session, person, transaction: transaction, ); seen.add( '$name: $sessions sessions, ' 'person ${stillThere == null ? 'gone' : 'there'}', ); return () async { final gone = await AuthUser.db.findById(session, person) == null; seen.add('after $name: person ${gone ? 'gone' : 'there'}'); }; }, ]; Future deleteAnna() => endpoints.account.delete( sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( anna.uuid, {}, authId: annasPhone.uuid, ), ), ); setUp(() async { seen = []; useBase( sessionBuilder, deletePersonData: [...noting('first'), ...noting('second')], ); session = sessionBuilder.build(); anna = await newAnonymousPerson(session); annasPhone = await signInNewDevice(session, anna); await signInNewDevice(session, anna); ben = await newAnonymousPerson(session); await signInNewDevice(session, ben); }); test('refuses callers who are not signed in', () async { await expectLater( endpoints.account.delete( sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ), ), throwsA(isA()), ); expect(seen, isEmpty); }); test('deletes the caller with their sessions and ways in, and nobody ' 'else', () async { await EmailIdentity.db.insert(session, [ for (final (person, hash) in [(anna, 'a' * 64), (ben, 'b' * 64)]) EmailIdentity( authUserId: person, addressHash: hash, createdAt: DateTime.now().toUtc(), ), ]); // A GitHub ID left from before (ADR 0020, D36) goes too. await GitHubAccount.db.insertRow( session, GitHubAccount(userIdentifier: '4711', authUserId: anna), ); await deleteAnna(); expect(await AuthUser.db.findById(session, anna), isNull); expect(await _sessionsOf(session, anna), 0); expect(await _anonymousAccountsOf(session, anna), 0); expect( await EmailIdentity.db.count( session, where: (t) => t.authUserId.equals(anna), ), 0, ); expect( await GitHubAccount.db.count( session, where: (t) => t.authUserId.equals(anna), ), 0, ); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await _sessionsOf(session, ben), 1); expect(await _anonymousAccountsOf(session, ben), 1); expect( await EmailIdentity.db.count( session, where: (t) => t.authUserId.equals(ben), ), 1, ); }); test("runs the host's deletions in order, once the sessions ended and " 'before the person goes, and what they leave for afterwards once ' 'it holds', () async { await deleteAnna(); expect(seen, [ 'first: 0 sessions, person there', 'second: 0 sessions, person there', 'after first: person gone', 'after second: person gone', ]); }); test("leaves everything as it was when a host's deletion fails, and " 'runs nothing afterwards', () async { useBase( sessionBuilder, deletePersonData: [ ...noting('first'), (_, _, _) async => throw StateError('the host failed'), ], ); await expectLater(deleteAnna(), throwsA(isA())); expect(await AuthUser.db.findById(session, anna), isNotNull); expect(await _sessionsOf(session, anna), 2); expect(await _anonymousAccountsOf(session, anna), 1); expect(seen, ['first: 0 sessions, person there']); }); }); withServerpod('Forgetting sign-up attempts', (sessionBuilder, _) { late Session session; final now = DateTime.now().toUtc(); setUp(() => session = sessionBuilder.build()); test('deletes those the rate limit is done with, with their IP ' 'addresses, and keeps the rest', () async { Future attempt(Duration ago) async => (await RateLimitedRequestAttempt.db.insertRow( session, RateLimitedRequestAttempt( domain: 'password_sign_in', source: 'proofs_per_address', key: '203.0.113.7', ipAddress: '203.0.113.7', attemptedAt: now.subtract(ago), ), )).id!; final old = await attempt(const Duration(days: 3)); final done = await attempt(const Duration(minutes: 61)); final recent = await attempt(const Duration(minutes: 59)); // Others may have been left by earlier runs. expect( await forgetOldSignUpAttempts(session, now: now), greaterThanOrEqualTo(2), ); for (final forgotten in [old, done]) { expect( await RateLimitedRequestAttempt.db.findById(session, forgotten), isNull, ); } expect( await RateLimitedRequestAttempt.db.findById(session, recent), isNotNull, ); }); }); withServerpod('Pruning anonymous people (ADR 0020, D25)', ( sessionBuilder, _, ) { late Session session; final now = DateTime.now().toUtc(); DateTime daysAgo(int days) => now.subtract(Duration(days: days)); /// Whom the host's deletion was asked to delete. late List deletedByHost; setUp(() { deletedByHost = []; useBase( sessionBuilder, deletePersonData: [ (_, person, _) async { deletedByHost.add(person); return null; }, ], ); session = sessionBuilder.build(); }); test('the module signs nobody in anonymously', () { expect( session.serverpod.endpoints.getConnectorByName('anonymousIdp'), isNull, ); }); test( 'deletes every anonymous account left, however new or lately used, ' "with the host's data of theirs", () async { final old = await newAnonymousPerson(session, createdAt: daysAgo(100)); final recentlyUsed = await newAnonymousPerson( session, createdAt: daysAgo(100), ); await _lastUsed(session, recentlyUsed, daysAgo(1)); final newcomer = await newAnonymousPerson(session); await signInNewDevice(session, newcomer); // Others may have been left by earlier runs. expect(await pruneAnonymousPeople(session), greaterThanOrEqualTo(3)); for (final gone in [old, recentlyUsed, newcomer]) { expect(await AuthUser.db.findById(session, gone), isNull); expect(await _sessionsOf(session, gone), 0); } expect(deletedByHost, containsAll([old, recentlyUsed, newcomer])); }, ); test('deletes nobody but anonymous accounts: never someone identified, ' "even with an anonymous account's row left, nor anyone without " 'one', () async { final identifiedPerson = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; // INSERT INTO "serverpod_auth_idp_anonymous_account" (…) VALUES (…) await AnonymousAccount.db.insertRow( session, AnonymousAccount(authUserId: identifiedPerson, createdAt: daysAgo(400)), ); await _lastUsed(session, identifiedPerson, daysAgo(365)); final noAnonymousAccount = (await AuthServices.instance.authUsers.create( session, )).id; await pruneAnonymousPeople(session); for (final kept in [identifiedPerson, noAnonymousAccount]) { expect(await AuthUser.db.findById(session, kept), isNotNull); expect(deletedByHost, isNot(contains(kept))); } expect(await _sessionsOf(session, identifiedPerson), 1); }); test('deletes at most as many as it is given at once', () async { for (var i = 0; i < 3; i++) { await newAnonymousPerson(session); } expect(await pruneAnonymousPeople(session, limit: 2), 2); }); }); } /// Signs [person] in on a device last used at [at]. Future _lastUsed(Session session, UuidValue person, DateTime at) async { final device = await signInNewDevice(session, person); final row = await ServerSideSession.db.findById(session, device); await ServerSideSession.db.updateRow(session, row!.copyWith(lastUsedAt: at)); } Future _sessionsOf(Session session, UuidValue person) => ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(person), ); Future _anonymousAccountsOf(Session session, UuidValue person) => AnonymousAccount.db.count( session, where: (t) => t.authUserId.equals(person), );