Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,43 @@ public ResponseEntity<ResultJson> deleteExtension(
}
}

@PostMapping(
path = "/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete",
produces = MediaType.APPLICATION_JSON_VALUE
)
@CrossOrigin
@Operation(summary = "Delete a review for an extension by a user")
@ApiResponse(
responseCode = "200",
description = "A success message is returned in JSON format",
content = @Content(schema = @Schema(implementation = ResultJson.class))
)
@ApiResponse(
responseCode = "404",
description = "Extension not found",
content = @Content()
)
@ApiResponse(
responseCode = "404",
description = "Review not found",
content = @Content()
)
public ResponseEntity<ResultJson> deleteReview(
@PathVariable String namespace,
@PathVariable String extension,
@PathVariable String provider,
@PathVariable String loginName
) {
try {
var adminUser = admins.checkAdminUser();
var result = admins.deleteReview(namespace, extension, loginName, provider);
admins.logAdminAction(adminUser, result);
return ResponseEntity.ok(result);
} catch (ErrorResultException exc) {
return exc.toResponseEntity();
}
}

@GetMapping(
path = "/admin/namespace/{namespaceName}",
produces = MediaType.APPLICATION_JSON_VALUE
Expand Down
41 changes: 39 additions & 2 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,43 @@ private String userNotFoundMessage(String user) {
return "User not found: " + user;
}

@Transactional(rollbackOn = ErrorResultException.class)
public ResultJson deleteReview(String namespace, String extensionName, String loginName, String provider) {
var extension = repositories.findExtension(extensionName, namespace);
if (extension == null || !extension.isActive()) {
var message = "Extension not found: " + NamingUtil.toExtensionId(namespace, extensionName);
throw new ErrorResultException(message, HttpStatus.NOT_FOUND);
}

var user = repositories.findUserByLoginName(provider, loginName);
if (user == null) {
throw new ErrorResultException(userNotFoundMessage(provider + "/" + loginName), HttpStatus.NOT_FOUND);
}

var reviews = repositories.findActiveReviews(extension, user);
if (reviews.isEmpty()) {
var message = "No active review for extension " + NamingUtil.toExtensionId(extension) + " and user " + loginName + " found";
throw new ErrorResultException(message, HttpStatus.NOT_FOUND);
}

for (var extReview : reviews) {
deleteReview(extReview);
}

return ResultJson.success("Deleted review from " + loginName + " for " + NamingUtil.toExtensionId(extension));
}

private void deleteReview(ExtensionReview review) {
entityManager.remove(review);

var extension = review.getExtension();
extension.setAverageRating(repositories.getAverageReviewRating(extension));
extension.setReviewCount(repositories.countActiveReviews(extension));
search.updateSearchEntry(extension);
cache.evictExtensionJsons(extension);
cache.evictLatestExtensionVersion(extension);
}

@Transactional(rollbackOn = ErrorResultException.class)
public ResultJson editNamespaceMember(String namespaceName, String userName, String provider, String role,
UserData admin) throws ErrorResultException {
Expand Down Expand Up @@ -402,8 +439,8 @@ public ResultJson revokePublisherContributions(String provider, String loginName
}

var result = ResultJson.success("Deactivated " + deactivatedTokenCount
+ " tokens and deactivated " + deactivatedExtensionCount + " extensions of user "
+ provider + "/" + loginName + ".");
+ " tokens, deactivated " + deactivatedExtensionCount + " extensions of user "
+ provider + "/" + loginName + ".");
logAdminAction(admin, result);
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public interface ExtensionReviewRepository extends Repository<ExtensionReview, L

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

Streamable<ExtensionReview> findByUserAndActiveTrue(UserData user);

long countByExtensionAndActiveTrue(Extension extension);

@Cacheable(CACHE_AVERAGE_REVIEW_RATING)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ public Streamable<ExtensionReview> findAllReviews(Extension extension) {
return extensionReviewRepo.findByExtension(extension);
}

public Streamable<ExtensionReview> findActiveReviews(UserData user) {
return extensionReviewRepo.findByUserAndActiveTrue(user);
}

public Streamable<ExtensionReview> findActiveReviews(Extension extension, UserData user) {
return extensionReviewRepo.findByExtensionAndUserAndActiveTrue(extension, user);
}
Expand Down
115 changes: 108 additions & 7 deletions server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,12 @@
import org.springframework.transaction.support.TransactionTemplate;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
Expand Down Expand Up @@ -582,6 +578,7 @@ void testGetUserPublishInfo() throws Exception {
Mockito.when(repositories.findUserByLoginName("github", "test")).thenReturn(user);
Mockito.when(repositories.countActiveAccessTokens(user)).thenReturn(1L);
Mockito.when(repositories.findLatestVersions(user)).thenReturn(versions);
Mockito.when(repositories.findActiveReviews(user)).thenReturn(Streamable.empty());

mockMvc.perform(get("/admin/publisher/{provider}/{loginName}", "github", "test")
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
Expand Down Expand Up @@ -634,11 +631,14 @@ void testRevokePublisherAgreement() throws Exception {
Mockito.when(repositories.findVersionsByUser(user, true))
.thenReturn(Streamable.of(versions));

Mockito.when(repositories.findActiveReviews(user))
.thenReturn(Streamable.empty());

mockMvc.perform(post("/admin/publisher/{provider}/{loginName}/revoke", "github", "test")
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
.with(csrf().asHeader()))
.andExpect(status().isOk())
.andExpect(content().json(successJson("Deactivated 1 tokens and deactivated 1 extensions of user github/test.")));
.andExpect(content().json(successJson("Deactivated 1 tokens, deactivated 1 extensions of user github/test.")));

assertThat(token.isActive()).isFalse();
assertThat(versions.get(0).isActive()).isFalse();
Expand Down Expand Up @@ -1145,6 +1145,57 @@ void testChangeNamespaceAbortOnNewNamespaceExists() throws Exception {
.andExpect(content().json(errorJson("New namespace already exists: bar")));
}

@Test
void testDeleteReview() throws Exception {
mockAdminUser();
mockReviews();

mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foobar", "baz", "github", "user1")
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
.with(csrf().asHeader()))
.andExpect(status().isOk())
.andExpect(content().json(successJson("Deleted review from user1 for foobar.baz")));
}

@Test
void testDeleteReviewNotLoggedIn() throws Exception {
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1")
.with(csrf()))
.andExpect(status().isForbidden());
}

@Test
void testDeleteReviewNormalUser() throws Exception {
mockNormalUser();

mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1")
.with(user("test_user"))
.with(csrf().asHeader()))
.andExpect(status().isForbidden());
}

@Test
void testDeleteReviewUnknownExtension() throws Exception {
mockAdminUser();
mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1")
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
.with(csrf().asHeader()))
.andExpect(status().isNotFound())
.andExpect(content().json(errorJson("Extension not found: foo.bar")));
}

@Test
void testDeleteReviewNonExistingReview() throws Exception {
mockAdminUser();
mockReviews();

mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foobar", "baz", "github", "user3")
.with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN"))))
.with(csrf().asHeader()))
.andExpect(status().isNotFound())
.andExpect(content().json(errorJson("No active review for extension foobar.baz and user user3 found")));
}

//---------- UTILITY ----------//

private PersonalAccessToken mockAdminToken() {
Expand Down Expand Up @@ -1290,6 +1341,56 @@ private List<ExtensionVersion> mockExtension(int numberOfVersions, int numberOfB
return versions;
}

private List<ExtensionReview> mockReviews() {
var extVersions = mockExtension(1, 0, 0);
var extVersion = extVersions.get(0);
var extension = extVersion.getExtension();

var user1 = new UserData();
user1.setLoginName("user1");
var review1 = new ExtensionReview();
review1.setId(1);
review1.setExtension(extension);
review1.setUser(user1);
review1.setRating(3);
review1.setComment("Somewhat ok");
review1.setTimestamp(LocalDateTime.parse("2000-01-01T10:00"));
review1.setActive(true);

var user2 = new UserData();
user2.setLoginName("user2");
var review2 = new ExtensionReview();
review2.setId(2);
review2.setExtension(extension);
review2.setUser(user2);
review2.setRating(4);
review2.setComment("Quite good");
review2.setTimestamp(LocalDateTime.parse("2000-01-01T10:00"));
review2.setActive(true);

var user3 = new UserData();
user3.setLoginName("user3");

Mockito.when(repositories.findUserByLoginName(anyString(), eq("user1")))
.thenReturn(user1);
Mockito.when(repositories.findUserByLoginName(anyString(), eq("user2")))
.thenReturn(user2);
Mockito.when(repositories.findUserByLoginName(anyString(), eq("user3")))
.thenReturn(user3);

Mockito.when(repositories.findActiveReviews(any(), any()))
.thenReturn(Streamable.empty());
Mockito.when(repositories.findActiveReviews(extension, user1))
.thenReturn(Streamable.of(review1));
Mockito.when(repositories.findActiveReviews(extension, user2))
.thenReturn(Streamable.of(review2));

Mockito.when(repositories.findActiveReviews(extension))
.thenReturn(Streamable.of(review1, review2));

return List.of(review1, review2);
}

private String createVersion(int major) {
return major + ".0.0";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ void testExecuteQueries() {
() -> repositories.findActiveExtensions(namespace),
() -> repositories.findActiveReviews(extension),
() -> repositories.findActiveReviews(extension, userData),
() -> repositories.findActiveReviews(userData),
() -> repositories.findActiveVersions(extension),
() -> repositories.findAdminStatisticsByYearAndMonth(1997, 1),
() -> repositories.findAllActiveExtensions(),
Expand Down
3 changes: 2 additions & 1 deletion webui/src/components/button-with-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const ButtonWithProgress: FunctionComponent<PropsWithChildren<ButtonWithP
return <Box component='div' sx={[{ position: 'relative' }, ...(Array.isArray(props.sx) ? props.sx : [props.sx])]}>
<Button
variant='contained'
color='secondary'
color={props.color || 'secondary'}
disabled={props.working || props.error}
autoFocus={props.autoFocus}
onClick={props.onClick}
Expand Down Expand Up @@ -43,6 +43,7 @@ export const ButtonWithProgress: FunctionComponent<PropsWithChildren<ButtonWithP

export interface ButtonWithProgressProps {
working: boolean;
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | undefined;
error?: boolean;
autoFocus?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
Expand Down
16 changes: 16 additions & 0 deletions webui/src/extension-registry-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ export class ExtensionRegistryService {
});
}

async deleteUserReview(abortController: AbortController, extension: Extension, user: UserData): Promise<Readonly<SuccessResult | ErrorResult>> {
const csrfResponse = await this.getCsrfToken(abortController);
const headers: Record<string, string> = {};
if (!isError(csrfResponse)) {
const csrfToken = csrfResponse as CsrfTokenJson;
headers[csrfToken.header] = csrfToken.value;
}
return sendRequest({
abortController,
method: 'POST',
credentials: true,
endpoint: createAbsoluteURL([this.serverUrl, 'admin', 'extension', extension.namespace, extension.name, 'review', user.provider || 'github', user.loginName, 'delete']),
headers
});
}

getUser(abortController: AbortController): Promise<Readonly<UserData | ErrorResult>> {
return sendRequest({
abortController,
Expand Down
3 changes: 2 additions & 1 deletion webui/src/pages/admin-dashboard/publisher-revoke-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const PublisherRevokeDialog: FunctionComponent<PublisherRevokeDialogProps
const tokenCount = props.publisherInfo.activeAccessTokenNum;
const extensionCount = props.publisherInfo.extensions.filter(e => e.active).length;
const hasAgreement = props.publisherInfo.user.publisherAgreement?.status !== 'none';

return <>
<Button
variant='contained'
Expand All @@ -74,7 +75,7 @@ export const PublisherRevokeDialog: FunctionComponent<PublisherRevokeDialogProps
<DialogTitle >Revoke Publisher Contributions</DialogTitle>
<DialogContent>
<DialogContentText component='div'>
<Typography>
<Typography component='div'>
{
!tokenCount && !extensionCount && !hasAgreement ?
<>
Expand Down
Loading