Skip to content

Commit

Permalink
Optional caseId for moderating package version and user.
Browse files Browse the repository at this point in the history
  • Loading branch information
isoos committed May 17, 2024
1 parent b2af3da commit c7015cd
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 5 deletions.
18 changes: 17 additions & 1 deletion app/lib/account/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:neat_cache/neat_cache.dart';
import 'package:pub_dev/admin/models.dart';

import '../audit/models.dart';
import '../frontend/request_context.dart';
Expand Down Expand Up @@ -634,14 +635,29 @@ class AccountBackend {
/// Updates the moderated status of a user.
///
/// Expires all existing user sessions.
Future<void> updateModeratedFlag(String userId, bool isModerated) async {
Future<void> updateModeratedFlag(
String userId,
bool isModerated, {
required Key? refCaseKey,
required String? message,
}) async {
await withRetryTransaction(_db, (tx) async {
final user =
await tx.lookupOrNull<User>(_db.emptyKey.append(User, id: userId));
if (user == null) throw NotFoundException.resource('User:$userId');

user.updateIsModerated(isModerated: isModerated);
tx.insert(user);

if (refCaseKey != null) {
final mc = await tx.lookupValue<ModerationCase>(refCaseKey);
mc.addActionLogEntry(
ModerationSubject.user(user.email!).fqn,
isModerated ? ModerationAction.apply : ModerationAction.revert,
message,
);
tx.insert(mc);
}
});
await _expireAllSessions(userId);
await purgeAccountCache(userId: userId);
Expand Down
25 changes: 25 additions & 0 deletions app/lib/admin/actions/moderate_package_versions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import '../../shared/versions.dart';
import '../../task/backend.dart';
import '../../tool/maintenance/update_public_bucket.dart';

import '../backend.dart';
import '../models.dart';

import 'actions.dart';

final moderatePackageVersion = AdminAction(
Expand All @@ -23,12 +26,16 @@ final moderatePackageVersion = AdminAction(
Set the moderated flag on a package version (updating the flag and the timestamp).
''',
options: {
'case': 'The ModerationCase.caseId that this action is part of.',
'package': 'The package name to be moderated',
'version': 'The version to be moderated',
'state':
'Set moderated state true / false. Returns current state if omitted.',
'message': 'Optional message to store.'
},
invoke: (options) async {
final caseId = options['case'];

final package = options['package'];
InvalidInputException.check(
package != null && package.isNotEmpty,
Expand All @@ -51,6 +58,14 @@ Set the moderated flag on a package version (updating the flag and the timestamp
break;
}

final message = options['message'];

final refCase =
await adminBackend.loadAndVerifyModerationCaseForAdminAction(
caseId,
status: ModerationStatus.pending,
);

final p = await packageBackend.lookupPackage(package!);
if (p == null) {
throw NotFoundException.resource(package);
Expand Down Expand Up @@ -86,6 +101,16 @@ Set the moderated flag on a package version (updating the flag and the timestamp
pkg.updated = clock.now().toUtc();
tx.insert(pkg);

if (refCase != null) {
final mc = await tx.lookupValue<ModerationCase>(refCase.key);
mc.addActionLogEntry(
ModerationSubject.package(package, version).fqn,
valueToSet ? ModerationAction.apply : ModerationAction.revert,
message,
);
tx.insert(mc);
}

return v;
});

Expand Down
22 changes: 21 additions & 1 deletion app/lib/admin/actions/moderate_user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import '../../package/models.dart';
import '../../publisher/backend.dart';
import '../../shared/datastore.dart';

import '../backend.dart';
import '../models.dart';

import 'actions.dart';

final moderateUser = AdminAction(
Expand All @@ -25,17 +28,29 @@ and actions that they may be able to do will be blocked because of that.
The active web sessions of the user will be expired.
''',
options: {
'case': 'The ModerationCase.caseId that this action is part of.',
'user': 'The user-id or the email of the user to be moderated',
'state':
'Set moderated state true / false. Returns current state if omitted.',
'message': 'Optional message to store.'
},
invoke: (options) async {
final caseId = options['case'];

final userIdOrEmail = options['user'];
InvalidInputException.check(
userIdOrEmail != null && userIdOrEmail.isNotEmpty,
'user must be given',
);

final message = options['message'];

final refCase =
await adminBackend.loadAndVerifyModerationCaseForAdminAction(
caseId,
status: ModerationStatus.pending,
);

User? user;
if (looksLikeUserId(userIdOrEmail!)) {
user = await accountBackend.lookupUserById(userIdOrEmail);
Expand All @@ -60,7 +75,12 @@ The active web sessions of the user will be expired.

User? user2;
if (valueToSet != null) {
await accountBackend.updateModeratedFlag(user!.userId, valueToSet);
await accountBackend.updateModeratedFlag(
user!.userId,
valueToSet,
refCaseKey: refCase?.key,
message: message,
);
user2 = await accountBackend.lookupUserById(user.userId);

if (valueToSet) {
Expand Down
8 changes: 8 additions & 0 deletions app/lib/admin/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ class ModerationSubject {
);
}

factory ModerationSubject.user(String email) {
return ModerationSubject._(
kind: ModerationSubjectKind.user,
localName: email,
email: email,
);
}

/// Tries to parse subject [value] and returns a [ModerationSubject]
/// if it is recognized, or `null` if the format is not recognizable.
static ModerationSubject? tryParse(String value) {
Expand Down
30 changes: 28 additions & 2 deletions app/test/account/moderate_user_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import 'package:_pub_shared/data/publisher_api.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/shared/configuration.dart';
Expand All @@ -23,28 +25,48 @@ import '../shared/test_services.dart';

void main() {
group('Moderate User', () {
Future<ModerationCase> _report(String package) async {
await withHttpPubApiClient(
experimental: {'report'},
fn: (client) async {
await client.postReport(account_api.ReportForm(
email: 'user@pub.dev',
subject: 'package:$package',
message: 'Huston, we have a problem.',
));
},
);
final list = await dbService.query<ModerationCase>().run().toList();
return list.reduce((a, b) => a.opened.isAfter(b.opened) ? a : b);
}

Future<AdminInvokeActionResponse> _moderate(
String email, {
bool? state,
String caseId = 'none',
}) async {
final api = createPubApiClient(authToken: siteAdminToken);
return await api.adminInvokeAction(
'moderate-user',
AdminInvokeActionArguments(arguments: {
'case': caseId,
'user': email,
if (state != null) 'state': state.toString(),
}),
);
}

testWithProfile('update state and clearing it', fn: () async {
final mc = await _report('oxygen');

final r1 = await _moderate('user@pub.dev');
expect(r1.output, {
'userId': isNotEmpty,
'before': {'isModerated': false, 'moderatedAt': null},
});

final r2 = await _moderate('user@pub.dev', state: true);
final r2 =
await _moderate('user@pub.dev', caseId: mc.caseId, state: true);
expect(r2.output, {
'userId': isNotEmpty,
'before': {'isModerated': false, 'moderatedAt': null},
Expand All @@ -54,7 +76,8 @@ void main() {
expect(u2.isModerated, isTrue);
expect(u2.isVisible, false);

final r3 = await _moderate('user@pub.dev', state: false);
final r3 =
await _moderate('user@pub.dev', caseId: mc.caseId, state: false);
expect(r3.output, {
'userId': isNotEmpty,
'before': {'isModerated': true, 'moderatedAt': isNotEmpty},
Expand All @@ -63,6 +86,9 @@ void main() {
final u3 = await accountBackend.lookupUserByEmail('user@pub.dev');
expect(u3.isModerated, isFalse);
expect(u3.isVisible, true);

final mc2 = await adminBackend.lookupModerationCase(mc.caseId);
expect(mc2!.getActionLog().entries, hasLength(2));
});

testWithProfile('sign-in disabled', fn: () async {
Expand Down
28 changes: 27 additions & 1 deletion app/test/package/moderate_package_version_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@

import 'dart:typed_data';

import 'package:_pub_shared/data/account_api.dart';
import 'package:_pub_shared/data/admin_api.dart';
import 'package:_pub_shared/data/package_api.dart';
import 'package:clock/clock.dart';
import 'package:http/http.dart' as http;
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/fake/backend/fake_pub_worker.dart';
import 'package:pub_dev/package/backend.dart';
import 'package:pub_dev/scorecard/backend.dart';
import 'package:pub_dev/search/backend.dart';
import 'package:pub_dev/shared/configuration.dart';
import 'package:pub_dev/shared/datastore.dart';
import 'package:pub_dev/shared/exceptions.dart';
import 'package:pub_dev/task/backend.dart';
import 'package:pub_dev/tool/maintenance/update_public_bucket.dart';
Expand All @@ -27,15 +31,32 @@ import 'backend_test_utils.dart';

void main() {
group('Moderate package version', () {
Future<ModerationCase> _report(String package, String version) async {
await withHttpPubApiClient(
experimental: {'report'},
fn: (client) async {
await client.postReport(ReportForm(
email: 'user@pub.dev',
subject: 'package-version:$package/$version',
message: 'Huston, we have a problem.',
));
},
);
final list = await dbService.query<ModerationCase>().run().toList();
return list.reduce((a, b) => a.opened.isAfter(b.opened) ? a : b);
}

Future<AdminInvokeActionResponse> _moderate(
String package,
String version, {
String caseId = 'none',
bool? state,
}) async {
final api = createPubApiClient(authToken: siteAdminToken);
return await api.adminInvokeAction(
'moderate-package-version',
AdminInvokeActionArguments(arguments: {
'case': caseId,
'package': package,
'version': version,
if (state != null) 'state': state.toString(),
Expand All @@ -44,14 +65,16 @@ void main() {
}

testWithProfile('update state', fn: () async {
final mc = await _report('oxygen', '1.0.0');
final r1 = await _moderate('oxygen', '1.0.0');
expect(r1.output, {
'package': 'oxygen',
'version': '1.0.0',
'before': {'isModerated': false, 'moderatedAt': null},
});

final r2 = await _moderate('oxygen', '1.0.0', state: true);
final r2 =
await _moderate('oxygen', '1.0.0', caseId: mc.caseId, state: true);
expect(r2.output, {
'package': 'oxygen',
'version': '1.0.0',
Expand Down Expand Up @@ -81,6 +104,9 @@ void main() {
expect(optionsUpdates.isRetracted, true);
final p2 = await packageBackend.lookupPackage('oxygen');
expect(p2!.latestVersion, '2.0.0-dev');

final mc2 = await adminBackend.lookupModerationCase(mc.caseId);
expect(mc2!.getActionLog().entries, hasLength(1));
});

testWithProfile('clear moderation flag', fn: () async {
Expand Down

0 comments on commit c7015cd

Please sign in to comment.