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(), ), ]); 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: 'anonymous', source: 'account_creation', 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 abandoned anonymous people', (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( 'deletes anonymous people unused for 90 days, and only them, with ' "the host's data of theirs", () async { final unused = await newAnonymousPerson( session, createdAt: daysAgo(100), ); await _lastUsed(session, unused, daysAgo(95)); final neverSignedIn = await newAnonymousPerson( session, createdAt: daysAgo(100), ); final recentlyUsed = await newAnonymousPerson( session, createdAt: daysAgo(100), ); await _lastUsed(session, recentlyUsed, daysAgo(10)); final newcomer = await newAnonymousPerson( session, createdAt: daysAgo(5), ); final identifiedPerson = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await _lastUsed(session, identifiedPerson, daysAgo(365)); final pruned = await pruneAbandonedAnonymousPeople(session, now: now); expect(pruned, 2); expect(await AuthUser.db.findById(session, unused), isNull); expect(await AuthUser.db.findById(session, neverSignedIn), isNull); for (final kept in [recentlyUsed, newcomer, identifiedPerson]) { expect(await AuthUser.db.findById(session, kept), isNotNull); } expect(deletedByHost, unorderedEquals([unused, neverSignedIn])); }, ); }); } /// 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), );