Skip to content

Commit

Permalink
Deletion of moderated publishers and users (after three years) (#8060)
Browse files Browse the repository at this point in the history
  • Loading branch information
isoos authored Sep 25, 2024
1 parent 0dbb7eb commit a87dc2a
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 3 deletions.
62 changes: 59 additions & 3 deletions app/lib/admin/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<void> _removeUser(User user) async {
// Package.uploaders
final pool = Pool(10);
final futures = <Future>[];
Expand Down Expand Up @@ -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<Publisher>()
..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<Package>()
..filter('publisherId =', publisher.publisherId);
await for (final pkg in pkgQuery.run()) {
await withRetryTransaction(_db, (tx) async {
final p = await tx.lookupOrNull<Package>(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<PublisherMember>(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<User>()
..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}');
}
}
}
26 changes: 26 additions & 0 deletions app/test/admin/moderate_publisher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
}
25 changes: 25 additions & 0 deletions app/test/admin/moderate_user_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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';
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';
Expand Down Expand Up @@ -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);
});
});
}

0 comments on commit a87dc2a

Please sign in to comment.