Skip to content

Commit 7b744b6

Browse files
authored
Add action for inviting uploader to package (#8849)
1 parent 2f1dc4e commit 7b744b6

File tree

4 files changed

+133
-1
lines changed

4 files changed

+133
-1
lines changed

app/lib/admin/actions/actions.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'package:pub_dev/admin/actions/package_invite_uploader.dart';
6+
57
import '../../shared/exceptions.dart';
68
import 'download_counts_backfill.dart';
79
import 'download_counts_delete.dart';
@@ -111,6 +113,7 @@ final class AdminAction {
111113
packageDelete,
112114
packageDiscontinue,
113115
packageInfo,
116+
packageInviteUploader,
114117
packageLatestUpdate,
115118
packageReservationCreate,
116119
packageReservationDelete,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:pub_dev/package/backend.dart';
6+
7+
import '../../account/backend.dart';
8+
import '../../account/consent_backend.dart';
9+
import '../../shared/configuration.dart';
10+
import 'actions.dart';
11+
12+
final packageInviteUploader = AdminAction(
13+
name: 'package-invite-uploader',
14+
summary: 'invite an email to be uploader of a package',
15+
description: '''
16+
Sends an invite to <email> to become uploader of <package>.
17+
''',
18+
options: {
19+
'package': 'Package for which to add an uploader',
20+
'email': 'email to send invitation to',
21+
},
22+
invoke: (options) async {
23+
final packageName = options['package'] ??
24+
(throw InvalidInputException('Missing --package argument.'));
25+
26+
final invitedEmail = options['email'] ??
27+
(throw InvalidInputException('Missing --email argument.'));
28+
29+
final package = await packageBackend.lookupPackage(packageName);
30+
if (package == null) {
31+
throw NotFoundException.resource(packageName);
32+
}
33+
if (package.publisherId != null) {
34+
throw OperationForbiddenException.publisherOwnedPackageNoUploader(
35+
packageName, package.publisherId!);
36+
}
37+
final authenticatedAgent =
38+
await requireAuthenticatedAdmin(AdminPermission.invokeAction);
39+
40+
final inviteStatus = await consentBackend.invitePackageUploader(
41+
packageName: packageName,
42+
uploaderEmail: invitedEmail,
43+
agent: authenticatedAgent);
44+
45+
final uploaderUsers =
46+
await accountBackend.lookupUsersById(package.uploaders!);
47+
final isNotUploaderYet =
48+
!uploaderUsers.any((u) => u!.email == invitedEmail);
49+
InvalidInputException.check(
50+
isNotUploaderYet, '`$invitedEmail` is already an uploader.');
51+
52+
return {
53+
'message': 'Invited user',
54+
'package': packageName,
55+
'emailSent': inviteStatus.emailSent,
56+
'email': invitedEmail,
57+
};
58+
},
59+
);

app/lib/admin/actions/publisher_member_invite.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Sends an invite to <email> to become a member of <publisher>.
3333
}
3434

3535
final authenticatedAgent =
36-
await requireAuthenticatedAdmin(AdminPermission.removePackage);
36+
await requireAuthenticatedAdmin(AdminPermission.invokeAction);
3737

3838
await publisherBackend.verifyPublisherMemberInvite(
3939
publisherId, InviteMemberRequest(email: invitedEmail));

app/test/admin/api_tool_test.dart

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,76 @@ void main() {
147147
});
148148
});
149149

150+
group('package uploader invite', () {
151+
setupTestsWithAdminTokenIssues((client) => client.adminInvokeAction(
152+
'package-invite-uploader',
153+
AdminInvokeActionArguments(
154+
arguments: {'package': 'oxygen', 'email': '[email protected]'})));
155+
156+
testWithProfile('invite + accept', fn: () async {
157+
final adminClient = createPubApiClient(authToken: siteAdminToken);
158+
final adminOutput = await adminClient.adminInvokeAction(
159+
'package-invite-uploader',
160+
AdminInvokeActionArguments(
161+
arguments: {'package': 'oxygen', 'email': '[email protected]'}));
162+
163+
expect(adminOutput.output, {
164+
'message': 'Invited user',
165+
'package': 'oxygen',
166+
'emailSent': true,
167+
'email': '[email protected]',
168+
});
169+
170+
final email = fakeEmailSender.sentMessages.first;
171+
expect(email.subject, 'You have a new invitation to confirm on pub.dev');
172+
173+
final page = await auditBackend.listRecordsForPackage('oxygen');
174+
final r = page.records
175+
.firstWhere((e) => e.kind == AuditLogRecordKind.uploaderInvited);
176+
expect(r.summary,
177+
'`[email protected]` invited `[email protected]` to be an uploader for package `oxygen`.');
178+
179+
late String consentId;
180+
await withFakeAuthRequestContext(
181+
182+
() async {
183+
final authenticatedUser = await requireAuthenticatedWebUser();
184+
final user = authenticatedUser.user;
185+
final consentRow = await dbService.query<Consent>().run().single;
186+
final consent =
187+
await consentBackend.getConsent(consentRow.consentId, user);
188+
expect(consent.descriptionHtml, contains('/packages/oxygen'));
189+
expect(consent.descriptionHtml,
190+
contains('perform administrative actions'));
191+
consentId = consentRow.consentId;
192+
},
193+
);
194+
195+
final acceptingClient =
196+
await createFakeAuthPubApiClient(email: '[email protected]');
197+
final rs = await acceptingClient.resolveConsent(
198+
consentId, account_api.ConsentResult(granted: true));
199+
expect(rs.granted, true);
200+
201+
final page2 = await auditBackend.listRecordsForPackage('oxygen');
202+
final r2 = page2.records.firstWhere(
203+
(e) => e.kind == AuditLogRecordKind.uploaderInviteAccepted);
204+
expect(r2.summary,
205+
'`[email protected]` accepted uploader invite for package `oxygen`.');
206+
207+
final uploaders =
208+
(await packageBackend.lookupPackage('oxygen'))!.uploaders;
209+
expect(uploaders!, hasLength(2));
210+
expect(
211+
await Future.wait(uploaders.map((uploader) async =>
212+
(await accountBackend.lookupUserById(uploader))!.email)),
213+
{
214+
215+
216+
});
217+
});
218+
});
219+
150220
group('create and delete publisher', () {
151221
testWithProfile('publisher has packages', fn: () async {
152222
final p1 = await publisherBackend.lookupPublisher('example.com');

0 commit comments

Comments
 (0)