diff --git a/app/lib/admin/actions/actions.dart b/app/lib/admin/actions/actions.dart index 6805823b1c..827901b4f7 100644 --- a/app/lib/admin/actions/actions.dart +++ b/app/lib/admin/actions/actions.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:pub_dev/admin/actions/create_publisher.dart'; +import 'package:pub_dev/admin/actions/merge_moderated_package_into_existing.dart'; import 'package:pub_dev/admin/actions/publisher_block.dart'; import 'package:pub_dev/admin/actions/publisher_members_list.dart'; import 'package:pub_dev/admin/actions/remove_package_from_publisher.dart'; @@ -65,6 +66,7 @@ final class AdminAction { static List actions = [ createPublisher, + mergeModeratedPackageIntoExisting, publisherBlock, publisherMembersList, removePackageFromPublisher, diff --git a/app/lib/admin/actions/merge_moderated_package_into_existing.dart b/app/lib/admin/actions/merge_moderated_package_into_existing.dart new file mode 100644 index 0000000000..c5f02a252d --- /dev/null +++ b/app/lib/admin/actions/merge_moderated_package_into_existing.dart @@ -0,0 +1,71 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_dev/admin/actions/actions.dart'; +import 'package:pub_dev/package/backend.dart'; +import 'package:pub_dev/package/models.dart'; +import 'package:pub_dev/shared/datastore.dart'; + +final mergeModeratedPackageIntoExisting = AdminAction( + name: 'merge-moderated-package-into-existing', + summary: + 'Removes a ModeratedPackage tombstone and merges it into an existing Package entity.', + description: ''' +This action will remove a package moderation tombstone (ModeratedPackage) +entity from the Datastore, and merges its deleted version list into its +already existing Package entity. +The removal will unblock new uploads to the package. + +WARNING: Ownership information stored on the ModeratedPackage (e.g. publisher + or uploaders) will be lost. + +NOTE: The published versions will be rolled into Package.deletedVersions. + +Fails if that package has no ModeratedPackage tombstone. +Fails if that package has no existing Package entity. +''', + options: { + 'package': + 'The name of ModeratedPackage to merge into its existing Package.', + }, + invoke: (args) async { + final packageName = args['package'] as String; + + await withRetryTransaction(dbService, (tx) async { + // check ModeratedPackage existence + final mpKey = + dbService.emptyKey.append(ModeratedPackage, id: packageName); + final mp = await tx.lookupOrNull(mpKey); + InvalidInputException.check( + mp != null, 'ModeratedPackage does not exists.'); + + // check Package existence + final pKey = dbService.emptyKey.append(Package, id: packageName); + final p = await tx.lookupOrNull(pKey); + InvalidInputException.check(p != null, 'Package does not exists.'); + + // update deleted version list + final versionList = await tx.query(pKey).run().toList(); + final existingVersions = versionList.map((v) => v.version!).toSet(); + final deletedVersions = (mp!.versions ?? []) + .where((v) => !existingVersions.contains(v)) + .toList(); + if (deletedVersions.isNotEmpty) { + p!.deletedVersions = { + ...?p.deletedVersions, + ...deletedVersions, + }.toList(); + } + + tx.insert(p!); + tx.delete(mpKey); + }); + await purgePackageCache(packageName); + + return { + 'package': packageName, + 'merged': true, + }; + }, +); diff --git a/app/test/admin/api_actions_test.dart b/app/test/admin/api_actions_test.dart index beb5880c11..24671dd497 100644 --- a/app/test/admin/api_actions_test.dart +++ b/app/test/admin/api_actions_test.dart @@ -5,11 +5,16 @@ import 'dart:convert'; import 'package:_pub_shared/data/admin_api.dart'; +import 'package:clock/clock.dart'; import 'package:pub_dev/account/backend.dart'; import 'package:pub_dev/package/backend.dart'; +import 'package:pub_dev/package/models.dart'; import 'package:pub_dev/publisher/backend.dart'; +import 'package:pub_dev/shared/datastore.dart'; import 'package:test/test.dart'; +import '../package/backend_test_utils.dart'; +import '../shared/handlers_test_utils.dart'; import '../shared/test_models.dart'; import '../shared/test_services.dart'; @@ -107,4 +112,51 @@ void main() { final emails = await accountBackend.getEmailsOfUserIds(neon.uploaders!); expect(emails, {'admin@pub.dev'}); }); + + testWithProfile('merge existing moderated package into existing', + fn: () async { + final originalVersionList = await packageBackend.listVersions('oxygen'); + + // inject "bad" ModeratedPackage tombstone + await dbService.commit(inserts: [ + ModeratedPackage() + ..parentKey = dbService.emptyKey + ..id = 'oxygen' + ..name = 'oxygen' + ..moderated = clock.now().toUtc() + ..versions = [ + originalVersionList.versions.first.version, // existing + '8.99.100', // non-existing + ] + ..uploaders = [], + ]); + + // verify that new upload is blocked + final pubspecContent = generatePubspecYaml('oxygen', '9.0.0'); + final bytes = await packageArchiveBytes(pubspecContent: pubspecContent); + final rs1 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes(bytes); + await expectApiException( + rs1, + status: 400, + code: 'PackageRejected', + message: 'Package name oxygen is reserved', + ); + + // merge tombstone + final api = createPubApiClient(authToken: siteAdminToken); + final result = await api.adminInvokeAction( + 'merge-moderated-package-into-existing', + AdminInvokeActionArguments(arguments: {'package': 'oxygen'}), + ); + expect(result.output, { + 'package': 'oxygen', + 'merged': true, + }); + + // verify that upload is unblocked + final rs2 = await createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes(bytes); + expect(rs2.success.message, contains('Successfully uploaded')); + }); }