Skip to content

Commit

Permalink
Admin action for moderating a Publisher + tests. (dart-lang#7590)
Browse files Browse the repository at this point in the history
  • Loading branch information
isoos authored Apr 2, 2024
1 parent b3166aa commit e09a9aa
Show file tree
Hide file tree
Showing 9 changed files with 296 additions and 1 deletion.
2 changes: 2 additions & 0 deletions app/lib/admin/actions/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'delete_publisher.dart';
import 'merge_moderated_package_into_existing.dart';
import 'moderate_package.dart';
import 'moderate_package_versions.dart';
import 'moderate_publisher.dart';
import 'moderate_user.dart';
import 'publisher_block.dart';
import 'publisher_members_list.dart';
Expand Down Expand Up @@ -74,6 +75,7 @@ final class AdminAction {
mergeModeratedPackageIntoExisting,
moderatePackage,
moderatePackageVersion,
moderatePublisher,
moderateUser,
publisherBlock,
publisherMembersList,
Expand Down
71 changes: 71 additions & 0 deletions app/lib/admin/actions/moderate_publisher.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/publisher/backend.dart';
import 'package:pub_dev/publisher/models.dart';
import 'package:pub_dev/shared/datastore.dart';

import 'actions.dart';

final moderatePublisher = AdminAction(
name: 'moderate-publisher',
summary:
'Set the moderated flag on a publisher (making it invisible and unable to change).',
description: '''
Set the moderated flag on a publisher (updating the flag and the timestamp). The
moderated package page page says it is moderated, packages owned by publisher
can't be updated, administrators must not be able to update publisher options.
''',
options: {
'publisher': 'The publisherId to be moderated',
'state':
'Set moderated state true / false. Returns current state if omitted.',
},
invoke: (options) async {
final publisherId = options['publisher'];
InvalidInputException.check(
publisherId != null && publisherId.isNotEmpty,
'publisherId must be given',
);

final publisher = await publisherBackend.getPublisher(publisherId!);
InvalidInputException.check(
publisher != null, 'Unable to locate publisher.');

final state = options['state'];
bool? valueToSet;
switch (state) {
case 'true':
valueToSet = true;
break;
case 'false':
valueToSet = false;
break;
}

Publisher? publisher2;
if (valueToSet != null) {
publisher2 = await withRetryTransaction(dbService, (tx) async {
final p = await tx.lookupValue<Publisher>(publisher!.key);
p.updateIsModerated(isModerated: valueToSet!);
tx.insert(p);
return p;
});
await purgePublisherCache(publisherId: publisherId);
}

return {
'publisherId': publisher!.publisherId,
'before': {
'isModerated': publisher.isModerated,
'moderatedAt': publisher.moderatedAt?.toIso8601String(),
},
if (publisher2 != null)
'after': {
'isModerated': publisher2.isModerated,
'moderatedAt': publisher2.moderatedAt?.toIso8601String(),
},
};
},
);
7 changes: 7 additions & 0 deletions app/lib/frontend/handlers/publisher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ Future<shelf.Response> publisherPackagesPageHandler(
// domain name), but now we just have a formatted error page.
return formattedNotFoundHandler(request);
}
if (publisher.isModerated) {
final message = 'The publisher `$publisherId` has been moderated.';
return htmlResponse(
renderErrorPage(default404NotFound, message),
status: 404,
);
}

final searchForm = SearchForm.parse(request.requestedUri.queryParameters);
// redirect to the search page when any search or pagination is present
Expand Down
15 changes: 15 additions & 0 deletions app/lib/publisher/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ class PublisherBackend {

PublisherBackend(this._db);

/// Returns true for publishers that we may display.
Future<bool> isPublisherVisible(String publisherId) async {
final visible = await cache.publisherVisible(publisherId).get(() async {
final pKey = _db.emptyKey.append(Publisher, id: publisherId);
final p = await _db.lookupOrNull<Publisher>(pKey);
return p?.isVisible ?? false;
});
return visible!;
}

/// Loads a publisher. Returns `null` if it does not exists, or is blocked (not visible).
Future<Publisher?> getPublisher(String publisherId) async {
checkPublisherIdParam(publisherId);
Expand Down Expand Up @@ -126,6 +136,7 @@ class PublisherBackend {
/// Whether the User [userId] has admin permissions on the publisher.
Future<bool> isMemberAdmin(Publisher publisher, String? userId) async {
if (publisher.isBlocked) return false;
if (publisher.isModerated) return false;
if (userId == null) return false;
final member = await getPublisherMember(publisher, userId);
if (member == null) return false;
Expand Down Expand Up @@ -595,6 +606,9 @@ Future<Publisher> requirePublisherAdmin(
if (p == null) {
throw NotFoundException('Publisher $publisherId does not exists.');
}
if (p.isModerated) {
throw ModeratedException.publisher(publisherId);
}

final member = await publisherBackend._db
.lookupOrNull<PublisherMember>(p.key.append(PublisherMember, id: userId));
Expand All @@ -612,6 +626,7 @@ Future purgePublisherCache({String? publisherId}) async {
await Future.wait([
if (publisherId != null)
cache.uiPublisherPackagesPage(publisherId).purgeAndRepeat(),
if (publisherId != null) cache.publisherVisible(publisherId).purge(),
cache.uiPublisherListPage().purge(),
]);
}
Expand Down
9 changes: 8 additions & 1 deletion app/lib/publisher/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ class Publisher extends db.ExpandoModel<String> {
bool get hasContactEmail => contactEmail != null && contactEmail!.isNotEmpty;

/// Whether we should not list the publisher page in sitemap or promote it in search engines.
bool get isUnlisted => isBlocked || isAbandoned;
bool get isUnlisted => isBlocked || isAbandoned || isModerated;
bool get isVisible => !isUnlisted;

void updateIsModerated({required bool isModerated}) {
this.isModerated = isModerated;
moderatedAt = isModerated ? clock.now().toUtc() : null;
updated = clock.now().toUtc();
}
}

/// Derived publisher data.
Expand Down
14 changes: 14 additions & 0 deletions app/lib/search/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'package:meta/meta.dart';
// ignore: implementation_imports
import 'package:pana/src/dartdoc/pub_dartdoc_data.dart';
import 'package:pool/pool.dart';
import 'package:pub_dev/publisher/backend.dart';

import 'package:pub_dev/search/search_client.dart';
import 'package:pub_dev/shared/popularity_storage.dart';
Expand Down Expand Up @@ -258,6 +259,14 @@ class SearchBackend {
if (p == null || p.isNotVisible) {
throw RemovedPackageException();
}
if (p.publisherId != null) {
final publisherVisible =
await publisherBackend.isPublisherVisible(p.publisherId!);
if (!publisherVisible) {
throw RemovedPackageException();
}
}

// Get the scorecard with the latest version available with finished analysis.
final scoreCard =
await scoreCardBackend.getLatestFinishedScoreCardData(packageName);
Expand Down Expand Up @@ -382,6 +391,11 @@ class SearchBackend {
final query = _db.query<Package>();
await for (final p in query.run()) {
if (p.isNotVisible) continue;
if (p.publisherId != null) {
final publisherVisible =
await publisherBackend.isPublisherVisible(p.publisherId!);
if (!publisherVisible) continue;
}
final releases = await packageBackend.latestReleases(p);
yield PackageDocument(
package: p.name!,
Expand Down
3 changes: 3 additions & 0 deletions app/lib/shared/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,9 @@ class ModeratedException extends NotFoundException {

ModeratedException.packageVersion(String package, String version)
: super('PackageVersion "$package" "$version" has been moderated.');

ModeratedException.publisher(String publisherId)
: super('Publisher "$publisherId" has been moderated.');
}

/// Thrown when API endpoint is not implemented.
Expand Down
10 changes: 10 additions & 0 deletions app/lib/shared/redis_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,16 @@ class CachePatterns {
decode: (data) => PublisherPage.fromJson(data as Map<String, dynamic>),
))['$userId'];

Entry<bool> publisherVisible(String publisherId) => _cache
.withPrefix('publisher-visible/')
.withTTL(Duration(days: 7))
.withCodec(utf8)
.withCodec(json)
.withCodec(wrapAsCodec(
encode: (bool value) => value,
decode: (d) => d as bool,
))[publisherId];

Entry<String> atomFeedXml() => _cache
.withPrefix('atom-feed-xml/')
.withTTL(Duration(minutes: 3))
Expand Down
166 changes: 166 additions & 0 deletions app/test/publisher/moderate_publisher_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// 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_shared/data/admin_api.dart';
import 'package:_pub_shared/data/publisher_api.dart';
import 'package:clock/clock.dart';
import 'package:pub_dev/fake/backend/fake_auth_provider.dart';
import 'package:pub_dev/publisher/backend.dart';
import 'package:pub_dev/search/backend.dart';
import 'package:test/test.dart';

import '../frontend/handlers/_utils.dart';
import '../package/backend_test_utils.dart';
import '../shared/handlers_test_utils.dart';
import '../shared/test_models.dart';
import '../shared/test_services.dart';

void main() {
group('Moderate Publisher', () {
Future<AdminInvokeActionResponse> _moderate(
String publisher, {
bool? state,
}) async {
final api = createPubApiClient(authToken: siteAdminToken);
return await api.adminInvokeAction(
'moderate-publisher',
AdminInvokeActionArguments(arguments: {
'publisher': publisher,
if (state != null) 'state': state.toString(),
}),
);
}

testWithProfile('update state and clearing it', fn: () async {
final r1 = await _moderate('example.com');
expect(r1.output, {
'publisherId': 'example.com',
'before': {'isModerated': false, 'moderatedAt': null},
});

final r2 = await _moderate('example.com', state: true);
expect(r2.output, {
'publisherId': 'example.com',
'before': {'isModerated': false, 'moderatedAt': null},
'after': {'isModerated': true, 'moderatedAt': isNotEmpty},
});
final p2 = await publisherBackend.getPublisher('example.com');
expect(p2!.isModerated, isTrue);

final r3 = await _moderate('example.com', state: false);
expect(r3.output, {
'publisherId': 'example.com',
'before': {'isModerated': true, 'moderatedAt': isNotEmpty},
'after': {'isModerated': false, 'moderatedAt': isNull},
});
final p3 = await publisherBackend.getPublisher('example.com');
expect(p3!.isModerated, isFalse);
});

testWithProfile('not able to publish', fn: () async {
await _moderate('example.com', state: true);
final pubspecContent = generatePubspecYaml('neon', '2.0.0');
final bytes = await packageArchiveBytes(pubspecContent: pubspecContent);

await expectApiException(
createPubApiClient(authToken: adminClientToken)
.uploadPackageBytes(bytes),
code: 'InsufficientPermissions',
status: 403,
message: 'insufficient permissions to upload new versions',
);

await _moderate('example.com', state: false);
final message = await createPubApiClient(authToken: adminClientToken)
.uploadPackageBytes(bytes);
expect(message.success.message, contains('Successfully uploaded'));
});

testWithProfile('not able to update publisher options', fn: () async {
await _moderate('example.com', state: true);
final client = await createFakeAuthPubApiClient(email: 'admin@pub.dev');
await expectApiException(
client.updatePublisher(
'example.com', UpdatePublisherRequest(description: 'update')),
status: 404,
code: 'NotFound',
message: 'Publisher "example.com" has been moderated.',
);

await _moderate('example.com', state: false);
final rs = await client.updatePublisher(
'example.com', UpdatePublisherRequest(description: 'update'));
expect(rs.description, 'update');
});

testWithProfile('publisher pages show it is moderated', fn: () async {
final htmlUrls = [
'/publishers/example.com/packages',
'/publishers/example.com/unlisted-packages',
];
Future<void> expectAvailable() async {
for (final url in htmlUrls) {
await expectHtmlResponse(
await issueGet(url),
absent: ['moderated'],
present: ['/publishers/example.com/'],
);
}
}

await expectAvailable();

await _moderate('example.com', state: true);
for (final url in htmlUrls) {
await expectHtmlResponse(
await issueGet(url),
status: 404,
absent: ['/publishers/example.com/'],
present: ['moderated'],
);
}

await _moderate('example.com', state: false);
await expectAvailable();
});

testWithProfile('not included in search', fn: () async {
await searchBackend.doCreateAndUpdateSnapshot(
FakeGlobalLockClaim(clock.now().add(Duration(seconds: 3))),
concurrency: 2,
sleepDuration: Duration(milliseconds: 300),
);
final docs = await searchBackend.fetchSnapshotDocuments();
expect(docs!.where((d) => d.package == 'neon'), isNotEmpty);

await _moderate('example.com', state: true);

final minimumIndex =
await searchBackend.loadMinimumPackageIndex().toList();
expect(minimumIndex.where((e) => e.package == 'neon'), isEmpty);

await searchBackend.doCreateAndUpdateSnapshot(
FakeGlobalLockClaim(clock.now().add(Duration(seconds: 3))),
concurrency: 2,
sleepDuration: Duration(milliseconds: 300),
);
final docs2 = await searchBackend.fetchSnapshotDocuments();
expect(docs2!.where((d) => d.package == 'neon'), isEmpty);

await _moderate('example.com', state: false);

final minimumIndex2 =
await searchBackend.loadMinimumPackageIndex().toList();
expect(minimumIndex2.where((e) => e.package == 'neon'), isNotEmpty);

await searchBackend.doCreateAndUpdateSnapshot(
FakeGlobalLockClaim(clock.now().add(Duration(seconds: 3))),
concurrency: 2,
sleepDuration: Duration(milliseconds: 300),
);
final docs3 = await searchBackend.fetchSnapshotDocuments();
expect(docs3!.where((d) => d.package == 'neon'), isNotEmpty);
});
});
}

0 comments on commit e09a9aa

Please sign in to comment.