Skip to content

Commit

Permalink
Admin action to fix lingering ModeratedPackage entities. (dart-lang#7359
Browse files Browse the repository at this point in the history
)
  • Loading branch information
isoos authored Jan 8, 2024
1 parent 238ea7d commit f3da516
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 0 deletions.
2 changes: 2 additions & 0 deletions app/lib/admin/actions/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,6 +66,7 @@ final class AdminAction {

static List<AdminAction> actions = [
createPublisher,
mergeModeratedPackageIntoExisting,
publisherBlock,
publisherMembersList,
removePackageFromPublisher,
Expand Down
71 changes: 71 additions & 0 deletions app/lib/admin/actions/merge_moderated_package_into_existing.dart
Original file line number Diff line number Diff line change
@@ -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<ModeratedPackage>(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<Package>(pKey);
InvalidInputException.check(p != null, 'Package does not exists.');

// update deleted version list
final versionList = await tx.query<PackageVersion>(pKey).run().toList();
final existingVersions = versionList.map((v) => v.version!).toSet();
final deletedVersions = (mp!.versions ?? <String>[])
.where((v) => !existingVersions.contains(v))
.toList();
if (deletedVersions.isNotEmpty) {
p!.deletedVersions = <String>{
...?p.deletedVersions,
...deletedVersions,
}.toList();
}

tx.insert(p!);
tx.delete(mpKey);
});
await purgePackageCache(packageName);

return {
'package': packageName,
'merged': true,
};
},
);
52 changes: 52 additions & 0 deletions app/test/admin/api_actions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -107,4 +112,51 @@ void main() {
final emails = await accountBackend.getEmailsOfUserIds(neon.uploaders!);
expect(emails, {'[email protected]'});
});

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'));
});
}

0 comments on commit f3da516

Please sign in to comment.