From a87dc2af0853f05f5a0d0e57b037b3977bd0adb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20So=C3=B3s?= Date: Wed, 25 Sep 2024 16:32:30 +0200 Subject: [PATCH] Deletion of moderated publishers and users (after three years) (#8060) --- app/lib/admin/backend.dart | 62 ++++++++++++++++++++- app/test/admin/moderate_publisher_test.dart | 26 +++++++++ app/test/admin/moderate_user_test.dart | 25 +++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/app/lib/admin/backend.dart b/app/lib/admin/backend.dart index 0cfe7caac7..e76878ce28 100644 --- a/app/lib/admin/backend.dart +++ b/app/lib/admin/backend.dart @@ -150,15 +150,21 @@ class AdminBackend { /// Removes user from the Datastore and updates the packages and other /// entities they may have controlled. + /// + /// Verifies the current authenticated user for admin permissions. Future removeUser(String userId) async { final caller = await requireAuthenticatedAdmin(AdminPermission.removeUsers); final user = await accountBackend.lookupUserById(userId); if (user == null) return; if (user.isDeleted) return; - _logger.info('${caller.displayId}) initiated the delete ' 'of ${user.userId} (${user.email})'); + await _removeUser(user); + } + /// Removes user from the Datastore and updates the packages and other + /// entities they may have controlled. + Future _removeUser(User user) async { // Package.uploaders final pool = Pool(10); final futures = []; @@ -874,7 +880,57 @@ class AdminBackend { 'Deleted moderated package version: ${version.qualifiedVersionKey}'); } - // TODO: delete publisher instances - // TODO: mark user instances deleted + // delete publishers + final publisherQuery = _db.query() + ..filter('moderatedAt <', before) + ..order('moderatedAt'); + await for (final publisher in publisherQuery.run()) { + // sanity check + if (!publisher.isModerated) { + continue; + } + + _logger.info('Deleting moderated publisher: ${publisher.publisherId}'); + + // removes packages of this publisher, no uploaders will be set, marks discontinued + final pkgQuery = _db.query() + ..filter('publisherId =', publisher.publisherId); + await for (final pkg in pkgQuery.run()) { + await withRetryTransaction(_db, (tx) async { + final p = await tx.lookupOrNull(pkg.key); + if (p == null) return; + if (p.publisherId != publisher.publisherId) return; + + p.publisherId = null; + p.updated = clock.now().toUtc(); + p.isDiscontinued = true; + tx.insert(p); + }); + } + + // removes publisher members + await _db.deleteWithQuery( + _db.query(ancestorKey: publisher.key)); + + // removes publisher entity + await _db.commit(deletes: [publisher.key]); + + _logger.info('Deleted moderated publisher: ${publisher.publisherId}'); + } + + // mark user instances deleted + final userQuery = _db.query() + ..filter('moderatedAt <', before) + ..order('moderatedAt'); + await for (final user in userQuery.run()) { + // sanity check + if (!user.isModerated || user.isDeleted) { + continue; + } + + _logger.info('Deleting moderated user: ${user.userId}'); + await _removeUser(user); + _logger.info('Deleting moderated user: ${user.userId}'); + } } } diff --git a/app/test/admin/moderate_publisher_test.dart b/app/test/admin/moderate_publisher_test.dart index a4b5c4e346..94b2828abf 100644 --- a/app/test/admin/moderate_publisher_test.dart +++ b/app/test/admin/moderate_publisher_test.dart @@ -6,8 +6,10 @@ import 'package:_pub_shared/data/account_api.dart'; import 'package:_pub_shared/data/admin_api.dart'; import 'package:_pub_shared/data/publisher_api.dart'; import 'package:clock/clock.dart'; +import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/admin/models.dart'; import 'package:pub_dev/fake/backend/fake_auth_provider.dart'; +import 'package:pub_dev/package/backend.dart'; import 'package:pub_dev/publisher/backend.dart'; import 'package:pub_dev/search/backend.dart'; import 'package:pub_dev/shared/datastore.dart'; @@ -204,5 +206,29 @@ void main() { message: 'ModerationCase.status ("no-action") != "pending".', ); }); + + testWithProfile('cleanup deletes datastore entities and abandons packages', + fn: () async { + // moderate and cleanup + await _moderate('example.com', state: true, caseId: 'none'); + await adminBackend.deleteModeratedSubjects(before: clock.now().toUtc()); + + // no publisher or member + expect(await publisherBackend.getPublisher('example.com'), isNull); + expect( + await publisherBackend.listPublisherMembers('example.com'), + isEmpty, + ); + + // publisher package has no publisher or uploader + final pkg = await packageBackend.lookupPackage('neon'); + expect(pkg!.publisherId, isNull); + expect(pkg.uploaders, isEmpty); + + // other packages are not affected + final other = await packageBackend.lookupPackage('oxygen'); + expect(other!.isDiscontinued, false); + expect(other.uploaders, isNotEmpty); + }); }); } diff --git a/app/test/admin/moderate_user_test.dart b/app/test/admin/moderate_user_test.dart index 48f5c68b06..82a36875a9 100644 --- a/app/test/admin/moderate_user_test.dart +++ b/app/test/admin/moderate_user_test.dart @@ -6,6 +6,7 @@ import 'package:_pub_shared/data/account_api.dart' as account_api; import 'package:_pub_shared/data/admin_api.dart'; import 'package:_pub_shared/data/package_api.dart'; import 'package:_pub_shared/data/publisher_api.dart'; +import 'package:clock/clock.dart'; import 'package:pub_dev/account/auth_provider.dart'; import 'package:pub_dev/account/backend.dart'; import 'package:pub_dev/account/models.dart'; @@ -13,6 +14,7 @@ import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/admin/models.dart'; import 'package:pub_dev/fake/backend/fake_auth_provider.dart'; import 'package:pub_dev/package/backend.dart'; +import 'package:pub_dev/publisher/backend.dart'; import 'package:pub_dev/shared/configuration.dart'; import 'package:pub_dev/shared/datastore.dart'; import 'package:test/test.dart'; @@ -282,5 +284,28 @@ void main() { expect(p2!.publisherId, isNotEmpty); expect(p2.isDiscontinued, true); }); + + testWithProfile('cleanup deletes datastore entities', fn: () async { + // moderate and cleanup + final origUser = await accountBackend.lookupUserByEmail('admin@pub.dev'); + await _moderate('admin@pub.dev', state: true, reason: 'policy-violation'); + await adminBackend.deleteModeratedSubjects(before: clock.now().toUtc()); + + // entity is marked as deleted + final user = await accountBackend.lookupUserById(origUser.userId); + expect(user!.isDeleted, true); + + // package has no uploader + final pkg = await packageBackend.lookupPackage('oxygen'); + expect(pkg!.uploaders, isEmpty); + expect(pkg.isDiscontinued, true); + + // publisher has no members + final publisher = await publisherBackend.getPublisher('example.com'); + expect(publisher!.isAbandoned, true); + final members = + await publisherBackend.listPublisherMembers('example.com'); + expect(members, isEmpty); + }); }); }