Skip to content

Commit 7090c81

Browse files
authored
Support removing arbitrary reviews for admins (#1403)
* add remove review feature * simplify removal, add confirm dialog * add changelog entry
1 parent 8b41620 commit 7090c81

14 files changed

Lines changed: 339 additions & 47 deletions

File tree

server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,43 @@ public ResponseEntity<ResultJson> deleteExtension(
277277
}
278278
}
279279

280+
@PostMapping(
281+
path = "/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete",
282+
produces = MediaType.APPLICATION_JSON_VALUE
283+
)
284+
@CrossOrigin
285+
@Operation(summary = "Delete a review for an extension by a user")
286+
@ApiResponse(
287+
responseCode = "200",
288+
description = "A success message is returned in JSON format",
289+
content = @Content(schema = @Schema(implementation = ResultJson.class))
290+
)
291+
@ApiResponse(
292+
responseCode = "404",
293+
description = "Extension not found",
294+
content = @Content()
295+
)
296+
@ApiResponse(
297+
responseCode = "404",
298+
description = "Review not found",
299+
content = @Content()
300+
)
301+
public ResponseEntity<ResultJson> deleteReview(
302+
@PathVariable String namespace,
303+
@PathVariable String extension,
304+
@PathVariable String provider,
305+
@PathVariable String loginName
306+
) {
307+
try {
308+
var adminUser = admins.checkAdminUser();
309+
var result = admins.deleteReview(namespace, extension, loginName, provider);
310+
admins.logAdminAction(adminUser, result);
311+
return ResponseEntity.ok(result);
312+
} catch (ErrorResultException exc) {
313+
return exc.toResponseEntity();
314+
}
315+
}
316+
280317
@GetMapping(
281318
path = "/admin/namespace/{namespaceName}",
282319
produces = MediaType.APPLICATION_JSON_VALUE

server/src/main/java/org/eclipse/openvsx/admin/AdminService.java

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,43 @@ private String userNotFoundMessage(String user) {
249249
return "User not found: " + user;
250250
}
251251

252+
@Transactional(rollbackOn = ErrorResultException.class)
253+
public ResultJson deleteReview(String namespace, String extensionName, String loginName, String provider) {
254+
var extension = repositories.findExtension(extensionName, namespace);
255+
if (extension == null || !extension.isActive()) {
256+
var message = "Extension not found: " + NamingUtil.toExtensionId(namespace, extensionName);
257+
throw new ErrorResultException(message, HttpStatus.NOT_FOUND);
258+
}
259+
260+
var user = repositories.findUserByLoginName(provider, loginName);
261+
if (user == null) {
262+
throw new ErrorResultException(userNotFoundMessage(provider + "/" + loginName), HttpStatus.NOT_FOUND);
263+
}
264+
265+
var reviews = repositories.findActiveReviews(extension, user);
266+
if (reviews.isEmpty()) {
267+
var message = "No active review for extension " + NamingUtil.toExtensionId(extension) + " and user " + loginName + " found";
268+
throw new ErrorResultException(message, HttpStatus.NOT_FOUND);
269+
}
270+
271+
for (var extReview : reviews) {
272+
deleteReview(extReview);
273+
}
274+
275+
return ResultJson.success("Deleted review from " + loginName + " for " + NamingUtil.toExtensionId(extension));
276+
}
277+
278+
private void deleteReview(ExtensionReview review) {
279+
entityManager.remove(review);
280+
281+
var extension = review.getExtension();
282+
extension.setAverageRating(repositories.getAverageReviewRating(extension));
283+
extension.setReviewCount(repositories.countActiveReviews(extension));
284+
search.updateSearchEntry(extension);
285+
cache.evictExtensionJsons(extension);
286+
cache.evictLatestExtensionVersion(extension);
287+
}
288+
252289
@Transactional(rollbackOn = ErrorResultException.class)
253290
public ResultJson editNamespaceMember(String namespaceName, String userName, String provider, String role,
254291
UserData admin) throws ErrorResultException {
@@ -402,8 +439,8 @@ public ResultJson revokePublisherContributions(String provider, String loginName
402439
}
403440

404441
var result = ResultJson.success("Deactivated " + deactivatedTokenCount
405-
+ " tokens and deactivated " + deactivatedExtensionCount + " extensions of user "
406-
+ provider + "/" + loginName + ".");
442+
+ " tokens, deactivated " + deactivatedExtensionCount + " extensions of user "
443+
+ provider + "/" + loginName + ".");
407444
logAdminAction(admin, result);
408445
return result;
409446
}

server/src/main/java/org/eclipse/openvsx/repositories/ExtensionReviewRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public interface ExtensionReviewRepository extends Repository<ExtensionReview, L
2727

2828
Streamable<ExtensionReview> findByExtensionAndUserAndActiveTrue(Extension extension, UserData user);
2929

30+
Streamable<ExtensionReview> findByUserAndActiveTrue(UserData user);
31+
3032
long countByExtensionAndActiveTrue(Extension extension);
3133

3234
@Cacheable(CACHE_AVERAGE_REVIEW_RATING)

server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ public Streamable<ExtensionReview> findAllReviews(Extension extension) {
275275
return extensionReviewRepo.findByExtension(extension);
276276
}
277277

278+
public Streamable<ExtensionReview> findActiveReviews(UserData user) {
279+
return extensionReviewRepo.findByUserAndActiveTrue(user);
280+
}
281+
278282
public Streamable<ExtensionReview> findActiveReviews(Extension extension, UserData user) {
279283
return extensionReviewRepo.findByExtensionAndUserAndActiveTrue(extension, user);
280284
}

server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,12 @@
5353
import org.springframework.transaction.support.TransactionTemplate;
5454

5555
import java.time.LocalDateTime;
56-
import java.util.ArrayList;
57-
import java.util.Collections;
58-
import java.util.List;
59-
import java.util.Map;
56+
import java.util.*;
6057
import java.util.function.Consumer;
6158
import java.util.stream.Collectors;
6259

6360
import static org.assertj.core.api.Assertions.assertThat;
64-
import static org.mockito.ArgumentMatchers.any;
65-
import static org.mockito.ArgumentMatchers.anyCollection;
61+
import static org.mockito.ArgumentMatchers.*;
6662
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
6763
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
6864
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -583,6 +579,7 @@ void testGetUserPublishInfo() throws Exception {
583579
Mockito.when(repositories.findUserByLoginName("github", "test")).thenReturn(user);
584580
Mockito.when(repositories.countActiveAccessTokens(user)).thenReturn(1L);
585581
Mockito.when(repositories.findLatestVersions(user)).thenReturn(versions);
582+
Mockito.when(repositories.findActiveReviews(user)).thenReturn(Streamable.empty());
586583

587584
mockMvc.perform(get("/admin/publisher/{provider}/{loginName}", "github", "test")
588585
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
@@ -635,11 +632,14 @@ void testRevokePublisherAgreement() throws Exception {
635632
Mockito.when(repositories.findVersionsByUser(user, true))
636633
.thenReturn(Streamable.of(versions));
637634

635+
Mockito.when(repositories.findActiveReviews(user))
636+
.thenReturn(Streamable.empty());
637+
638638
mockMvc.perform(post("/admin/publisher/{provider}/{loginName}/revoke", "github", "test")
639639
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
640640
.with(csrf().asHeader()))
641641
.andExpect(status().isOk())
642-
.andExpect(content().json(successJson("Deactivated 1 tokens and deactivated 1 extensions of user github/test.")));
642+
.andExpect(content().json(successJson("Deactivated 1 tokens, deactivated 1 extensions of user github/test.")));
643643

644644
assertThat(token.isActive()).isFalse();
645645
assertThat(versions.get(0).isActive()).isFalse();
@@ -1146,6 +1146,57 @@ void testChangeNamespaceAbortOnNewNamespaceExists() throws Exception {
11461146
.andExpect(content().json(errorJson("New namespace already exists: bar")));
11471147
}
11481148

1149+
@Test
1150+
void testDeleteReview() throws Exception {
1151+
mockAdminUser();
1152+
mockReviews();
1153+
1154+
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foobar", "baz", "github", "user1")
1155+
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
1156+
.with(csrf().asHeader()))
1157+
.andExpect(status().isOk())
1158+
.andExpect(content().json(successJson("Deleted review from user1 for foobar.baz")));
1159+
}
1160+
1161+
@Test
1162+
void testDeleteReviewNotLoggedIn() throws Exception {
1163+
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1")
1164+
.with(csrf()))
1165+
.andExpect(status().isForbidden());
1166+
}
1167+
1168+
@Test
1169+
void testDeleteReviewNormalUser() throws Exception {
1170+
mockNormalUser();
1171+
1172+
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1")
1173+
.with(user("test_user"))
1174+
.with(csrf().asHeader()))
1175+
.andExpect(status().isForbidden());
1176+
}
1177+
1178+
@Test
1179+
void testDeleteReviewUnknownExtension() throws Exception {
1180+
mockAdminUser();
1181+
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1")
1182+
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
1183+
.with(csrf().asHeader()))
1184+
.andExpect(status().isNotFound())
1185+
.andExpect(content().json(errorJson("Extension not found: foo.bar")));
1186+
}
1187+
1188+
@Test
1189+
void testDeleteReviewNonExistingReview() throws Exception {
1190+
mockAdminUser();
1191+
mockReviews();
1192+
1193+
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foobar", "baz", "github", "user3")
1194+
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
1195+
.with(csrf().asHeader()))
1196+
.andExpect(status().isNotFound())
1197+
.andExpect(content().json(errorJson("No active review for extension foobar.baz and user user3 found")));
1198+
}
1199+
11491200
//---------- UTILITY ----------//
11501201

11511202
private PersonalAccessToken mockAdminToken() {
@@ -1291,6 +1342,56 @@ private List<ExtensionVersion> mockExtension(int numberOfVersions, int numberOfB
12911342
return versions;
12921343
}
12931344

1345+
private List<ExtensionReview> mockReviews() {
1346+
var extVersions = mockExtension(1, 0, 0);
1347+
var extVersion = extVersions.get(0);
1348+
var extension = extVersion.getExtension();
1349+
1350+
var user1 = new UserData();
1351+
user1.setLoginName("user1");
1352+
var review1 = new ExtensionReview();
1353+
review1.setId(1);
1354+
review1.setExtension(extension);
1355+
review1.setUser(user1);
1356+
review1.setRating(3);
1357+
review1.setComment("Somewhat ok");
1358+
review1.setTimestamp(LocalDateTime.parse("2000-01-01T10:00"));
1359+
review1.setActive(true);
1360+
1361+
var user2 = new UserData();
1362+
user2.setLoginName("user2");
1363+
var review2 = new ExtensionReview();
1364+
review2.setId(2);
1365+
review2.setExtension(extension);
1366+
review2.setUser(user2);
1367+
review2.setRating(4);
1368+
review2.setComment("Quite good");
1369+
review2.setTimestamp(LocalDateTime.parse("2000-01-01T10:00"));
1370+
review2.setActive(true);
1371+
1372+
var user3 = new UserData();
1373+
user3.setLoginName("user3");
1374+
1375+
Mockito.when(repositories.findUserByLoginName(anyString(), eq("user1")))
1376+
.thenReturn(user1);
1377+
Mockito.when(repositories.findUserByLoginName(anyString(), eq("user2")))
1378+
.thenReturn(user2);
1379+
Mockito.when(repositories.findUserByLoginName(anyString(), eq("user3")))
1380+
.thenReturn(user3);
1381+
1382+
Mockito.when(repositories.findActiveReviews(any(), any()))
1383+
.thenReturn(Streamable.empty());
1384+
Mockito.when(repositories.findActiveReviews(extension, user1))
1385+
.thenReturn(Streamable.of(review1));
1386+
Mockito.when(repositories.findActiveReviews(extension, user2))
1387+
.thenReturn(Streamable.of(review2));
1388+
1389+
Mockito.when(repositories.findActiveReviews(extension))
1390+
.thenReturn(Streamable.of(review1, review2));
1391+
1392+
return List.of(review1, review2);
1393+
}
1394+
12941395
private String createVersion(int major) {
12951396
return major + ".0.0";
12961397
}

server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ void testExecuteQueries() {
100100
() -> repositories.findActiveExtensions(namespace),
101101
() -> repositories.findActiveReviews(extension),
102102
() -> repositories.findActiveReviews(extension, userData),
103+
() -> repositories.findActiveReviews(userData),
103104
() -> repositories.findActiveVersions(extension),
104105
() -> repositories.findAdminStatisticsByYearAndMonth(1997, 1),
105106
() -> repositories.findAllActiveExtensions(),

webui/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
This change log covers only the frontend library (webui) of Open VSX.
44

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- Support removing reviews by admins ([#1403](https://github.com/eclipse/openvsx/pull/1403))
10+
511
## [v0.17.1] (Jan. 2026)
612

713
### Dependencies

webui/src/components/button-with-progress.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const ButtonWithProgress: FunctionComponent<PropsWithChildren<ButtonWithP
1515
return <Box component='div' sx={[{ position: 'relative' }, ...(Array.isArray(props.sx) ? props.sx : [props.sx])]}>
1616
<Button
1717
variant='contained'
18-
color='secondary'
18+
color={props.color || 'secondary'}
1919
disabled={props.working || props.error}
2020
autoFocus={props.autoFocus}
2121
onClick={props.onClick}
@@ -43,6 +43,7 @@ export const ButtonWithProgress: FunctionComponent<PropsWithChildren<ButtonWithP
4343

4444
export interface ButtonWithProgressProps {
4545
working: boolean;
46+
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | undefined;
4647
error?: boolean;
4748
autoFocus?: boolean;
4849
onClick: MouseEventHandler<HTMLButtonElement>;

webui/src/extension-registry-service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,22 @@ export class ExtensionRegistryService {
209209
});
210210
}
211211

212+
async deleteUserReview(abortController: AbortController, extension: Extension, user: UserData): Promise<Readonly<SuccessResult | ErrorResult>> {
213+
const csrfResponse = await this.getCsrfToken(abortController);
214+
const headers: Record<string, string> = {};
215+
if (!isError(csrfResponse)) {
216+
const csrfToken = csrfResponse as CsrfTokenJson;
217+
headers[csrfToken.header] = csrfToken.value;
218+
}
219+
return sendRequest({
220+
abortController,
221+
method: 'POST',
222+
credentials: true,
223+
endpoint: createAbsoluteURL([this.serverUrl, 'admin', 'extension', extension.namespace, extension.name, 'review', user.provider || 'github', user.loginName, 'delete']),
224+
headers
225+
});
226+
}
227+
212228
getUser(abortController: AbortController): Promise<Readonly<UserData | ErrorResult>> {
213229
return sendRequest({
214230
abortController,

webui/src/pages/admin-dashboard/publisher-revoke-dialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const PublisherRevokeDialog: FunctionComponent<PublisherRevokeDialogProps
6161
const tokenCount = props.publisherInfo.activeAccessTokenNum;
6262
const extensionCount = props.publisherInfo.extensions.filter(e => e.active).length;
6363
const hasAgreement = props.publisherInfo.user.publisherAgreement?.status !== 'none';
64+
6465
return <>
6566
<Button
6667
variant='contained'
@@ -74,7 +75,7 @@ export const PublisherRevokeDialog: FunctionComponent<PublisherRevokeDialogProps
7475
<DialogTitle >Revoke Publisher Contributions</DialogTitle>
7576
<DialogContent>
7677
<DialogContentText component='div'>
77-
<Typography>
78+
<Typography component='div'>
7879
{
7980
!tokenCount && !extensionCount && !hasAgreement ?
8081
<>

0 commit comments

Comments
 (0)