From 393c8843b03d6310c31f6421e3e95be737ac9180 Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Wed, 19 Aug 2026 13:13:10 -0400 Subject: [PATCH 1/3] feat: Add a name squatting view to the admin dashboard To best fulfill the requirements of an easy to use system to manage typosquatting, this PR was created. This leverages the existing NAME_SQUATTING publish check that uses levenshtein distance to check for similarly named extensions post-publish. The new dashboard view allows an admin to either mark a found check as a false positive and clear the check, or to soft-delete any extensions that are found to be maliciously typo squatting another extension. Any admin action taken will also be logged in the admin log table to make auditing these changes possible. This check is left as unenforced as the check is too sensitive, and has many false positives. This does provide all of the features that were requested in the initial PRD for management of the system, outside of exclusion keywords. Part of #1949 Assisted-by: Claude Opus 5 --- .../openvsx/admin/NameSquattingAPI.java | 660 ++++++++++++++++++ .../json/NameSquattingActionRequest.java | 42 ++ .../json/NameSquattingActionResponseJson.java | 75 ++ .../json/NameSquattingActionResultJson.java | 101 +++ .../openvsx/json/NameSquattingCountsJson.java | 73 ++ .../json/NameSquattingFindingJson.java | 127 ++++ .../openvsx/json/NameSquattingFlagJson.java | 160 +++++ .../json/NameSquattingFlagListJson.java | 76 ++ .../openvsx/json/NameSquattingTargetJson.java | 53 ++ .../ExtensionValidationFailureRepository.java | 179 +++++ .../repositories/RepositoryService.java | 95 +++ .../openvsx/admin/NameSquattingAPITest.java | 542 ++++++++++++++ .../RepositoryServiceSmokeTest.java | 32 + webui/CHANGELOG.md | 6 + webui/src/extension-registry-service.ts | 123 ++++ webui/src/extension-registry-types.ts | 75 ++ .../admin-dashboard/admin-dashboard-routes.ts | 1 + .../pages/admin-dashboard/admin-dashboard.tsx | 9 + .../name-squatting-action-dialog.tsx | 111 +++ .../name-squatting/name-squatting-row.tsx | 185 +++++ .../name-squatting/name-squatting.tsx | 256 +++++++ .../name-squatting/use-name-squatting.ts | 101 +++ webui/test/setup.ts | 14 + .../name-squatting-row.spec.tsx | 79 +++ .../name-squatting/name-squatting.spec.tsx | 168 +++++ .../test/unit/support/name-squatting-data.ts | 53 ++ 26 files changed, 3396 insertions(+) create mode 100644 server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionRequest.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResponseJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResultJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingCountsJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingFindingJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagListJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/NameSquattingTargetJson.java create mode 100644 server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java create mode 100644 webui/src/pages/admin-dashboard/name-squatting/name-squatting-action-dialog.tsx create mode 100644 webui/src/pages/admin-dashboard/name-squatting/name-squatting-row.tsx create mode 100644 webui/src/pages/admin-dashboard/name-squatting/name-squatting.tsx create mode 100644 webui/src/pages/admin-dashboard/name-squatting/use-name-squatting.ts create mode 100644 webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting-row.spec.tsx create mode 100644 webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting.spec.tsx create mode 100644 webui/test/unit/support/name-squatting-data.ts diff --git a/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java new file mode 100644 index 000000000..fa8c723c8 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java @@ -0,0 +1,660 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.admin; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.Explode; +import io.swagger.v3.oas.annotations.enums.ParameterStyle; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.settings.MutatingOperation; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.TargetPlatformVersion; +import org.eclipse.openvsx.util.TimeUtil; + +/** + * REST API for moderating extensions that failed the name squatting publisher check. + *

+ * The check runs at publish time and behaves differently depending on whether it is enforced. When + * it is enforced, publication is blocked and no extension version is ever created, so the finding + * is only a record of a rejection. When it is not enforced, the finding is recorded for monitoring + * and the extension goes live - and it is those extensions an administrator needs to act on, by + * either clearing the check as a false positive or deactivating an extension that turns out to be + * malicious. + *

+ * Findings are grouped per extension because both actions apply to the extension as a whole. + */ +@RestController +@Validated +@RequestMapping("/admin/name-squatting") +@ApiResponse( + responseCode = "403", + description = "Administration role is required", + content = @Content() +) +public class NameSquattingAPI { + + private static final String CHECK_TYPE = SimilarityCheckService.CHECK_TYPE; + + /** The extension is live: it exists and has at least one active version. */ + private static final String STATE_PUBLISHED = "PUBLISHED"; + + /** The extension exists but all of its versions have been deactivated. */ + private static final String STATE_DEACTIVATED = "DEACTIVATED"; + + /** Publication was blocked by the check, so the extension was never created. */ + private static final String STATE_REJECTED = "REJECTED"; + + private final RepositoryService repositories; + private final AdminService admins; + private final LogService logs; + + public NameSquattingAPI(RepositoryService repositories, AdminService admins, LogService logs) { + this.repositories = repositories; + this.admins = admins; + this.logs = logs; + } + + /** + * List the extensions flagged by the name squatting check, one entry per extension, most + * recently flagged first. + */ + @GetMapping( + path = "", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get extensions flagged by the name squatting check") + @ApiResponse( + responseCode = "200", + description = "Paginated list of flagged extensions", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NameSquattingFlagListJson.class) + ) + ) + public ResponseEntity getFlaggedExtensions( + @RequestParam(required = false) + @Parameter(description = "Filter by publisher name (partial matches supported)") String publisher, + @RequestParam(required = false) + @Parameter(description = "Filter by namespace (partial matches supported)") String namespace, + @RequestParam(required = false) + @Parameter( + description = "Filter by display name or extension name (partial matches supported)" + ) String name, + @RequestParam(required = false) + @Parameter( + description = "Filter by what became of the extension (comma-separated for multiple values)", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema( + schema = @Schema( + type = "string", + allowableValues = { "PUBLISHED", "DEACTIVATED", "REJECTED" }, + example = "PUBLISHED" + ) + ) + ) List state, + @RequestParam(required = false) + @Parameter( + description = "Only include findings detected on or after this date (ISO 8601 format)" + ) String dateDetectedFrom, + @RequestParam(required = false) + @Parameter( + description = "Only include findings detected on or before this date (ISO 8601 format)" + ) String dateDetectedTo, + @RequestParam(defaultValue = "10") + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not be larger than 100") + @Parameter( + description = "Maximal number of entries to return", + schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "10") + ) int size, + @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") + @Parameter( + description = "Number of entries to skip", + schema = @Schema(type = "integer", minimum = "0", defaultValue = "0") + ) int offset, + @RequestParam(defaultValue = "desc") + @Parameter( + description = "Order by the most recent detection", + schema = @Schema(type = "string", allowableValues = { "asc", "desc" }, defaultValue = "desc") + ) String sortOrder + ) { + try { + admins.checkAdminUser(); + + var normalizedPublisher = normalizeSearch(publisher); + var normalizedNamespace = normalizeSearch(namespace); + var normalizedName = normalizeSearch(name); + var stateFilter = parseStateFilter(state); + var detectedFrom = parseUtcDateTime(dateDetectedFrom, "dateDetectedFrom"); + var detectedTo = parseUtcDateTime(dateDetectedTo, "dateDetectedTo"); + var ascending = normalizeSortOrder(sortOrder); + + var totalSize = repositories.countFlaggedExtensions( + CHECK_TYPE, + normalizedNamespace, + normalizedPublisher, + normalizedName, + detectedFrom, + detectedTo, + stateFilter); + + var keys = size == 0 + ? List.of() + : repositories.findFlaggedExtensionKeys( + CHECK_TYPE, + normalizedNamespace, + normalizedPublisher, + normalizedName, + detectedFrom, + detectedTo, + stateFilter, + ascending, + size, + offset); + + var flags = new ArrayList(); + for (var key : keys) { + var flag = toFlagJson(key, detectedFrom, detectedTo); + if (flag != null) { + flags.add(flag); + } + } + + var result = new NameSquattingFlagListJson(); + result.setOffset(offset); + result.setTotalSize((int) totalSize); + result.setFlags(flags); + + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(NameSquattingFlagListJson.class); + } + } + + /** + * Get the number of flagged extensions, broken down by what became of them. + */ + @GetMapping( + path = "/counts", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get counts of extensions flagged by the name squatting check") + @ApiResponse( + responseCode = "200", + description = "Counts of flagged extensions per state", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NameSquattingCountsJson.class) + ) + ) + public ResponseEntity getCounts( + @RequestParam(required = false) + @Parameter(description = "Filter by publisher name (partial matches supported)") String publisher, + @RequestParam(required = false) + @Parameter(description = "Filter by namespace (partial matches supported)") String namespace, + @RequestParam(required = false) + @Parameter( + description = "Filter by display name or extension name (partial matches supported)" + ) String name, + @RequestParam(required = false) + @Parameter( + description = "Only count findings detected on or after this date (ISO 8601 format)" + ) String dateDetectedFrom, + @RequestParam(required = false) + @Parameter( + description = "Only count findings detected on or before this date (ISO 8601 format)" + ) String dateDetectedTo + ) { + try { + admins.checkAdminUser(); + + var normalizedPublisher = normalizeSearch(publisher); + var normalizedNamespace = normalizeSearch(namespace); + var normalizedName = normalizeSearch(name); + var detectedFrom = parseUtcDateTime(dateDetectedFrom, "dateDetectedFrom"); + var detectedTo = parseUtcDateTime(dateDetectedTo, "dateDetectedTo"); + + var counts = new NameSquattingCountsJson(); + counts.setTotal( + countWithState(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, null)); + counts.setPublished( + countWithState(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, + new ExtensionStateFilter(true, false, false))); + counts.setDeactivated( + countWithState(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, + new ExtensionStateFilter(false, true, false))); + counts.setRejected( + countWithState(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, + new ExtensionStateFilter(false, false, true))); + + return ResponseEntity.ok(counts); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(NameSquattingCountsJson.class); + } + } + + /** + * Clear the name squatting findings recorded for one or more extensions, for use when an + * administrator judges the match to be a false positive. + *

+ * This removes the failure records, so the extension no longer shows up as flagged. The audit + * record of the check having run is kept in the scan check results, and the action itself is + * written to the admin log. + */ + @PostMapping( + path = "/clear", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Clear name squatting findings as a false positive") + @MutatingOperation + @ApiResponse( + responseCode = "200", + description = "Findings cleared", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NameSquattingActionResponseJson.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "No extensions were named in the request", + content = @Content() + ) + public ResponseEntity clearFindings( + @RequestBody NameSquattingActionRequest request + ) { + try { + var adminUser = admins.checkAdminUser(); + var targets = requireTargets(request); + + var results = new ArrayList(); + for (var target : targets) { + results.add(clearFindings(adminUser, target)); + } + + return ResponseEntity.ok(toActionResponse(results)); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(NameSquattingActionResponseJson.class); + } + } + + /** + * Soft-delete one or more flagged extensions, for use when the match turns out to be a real + * attempt at squatting a name. + *

+ * Every active version is deactivated, which makes the extension unavailable while keeping its + * records and reserving its version identities. Extensions whose publication was blocked by the + * check were never created and cannot be deleted. + */ + @PostMapping( + path = "/delete", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Soft-delete extensions flagged for name squatting") + @MutatingOperation + @ApiResponse( + responseCode = "200", + description = "Extensions deactivated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NameSquattingActionResponseJson.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "No extensions were named in the request", + content = @Content() + ) + public ResponseEntity deleteExtensions( + @RequestBody NameSquattingActionRequest request + ) { + try { + var adminUser = admins.checkAdminUser(); + var targets = requireTargets(request); + + var results = new ArrayList(); + for (var target : targets) { + results.add(deleteExtension(adminUser, target)); + } + + return ResponseEntity.ok(toActionResponse(results)); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(NameSquattingActionResponseJson.class); + } + } + + private NameSquattingActionResultJson clearFindings(UserData adminUser, NameSquattingTargetJson target) { + var namespaceName = target.getNamespace(); + var extensionName = target.getExtension(); + try { + var cleared = repositories.deleteValidationFailures(CHECK_TYPE, namespaceName, extensionName); + if (cleared == 0) { + return NameSquattingActionResultJson.failure( + namespaceName, + extensionName, + "No name squatting findings are recorded for this extension"); + } + + var message = String.format( + "Cleared %d name squatting finding%s for extension %s.%s as a false positive", + cleared, + cleared == 1 ? "" : "s", + namespaceName, + extensionName); + logs.logAction(adminUser, ResultJson.success(message)); + + return NameSquattingActionResultJson.success(namespaceName, extensionName, message); + } catch (ErrorResultException exc) { + return NameSquattingActionResultJson.failure(namespaceName, extensionName, exc.getMessage()); + } + } + + private NameSquattingActionResultJson deleteExtension(UserData adminUser, NameSquattingTargetJson target) { + var namespaceName = target.getNamespace(); + var extensionName = target.getExtension(); + + var extension = repositories.findExtension(extensionName, namespaceName); + if (extension == null) { + return NameSquattingActionResultJson.failure( + namespaceName, + extensionName, + "Extension does not exist, its publication was blocked by the check"); + } + + var targetVersions = activeTargetVersions(extension); + if (targetVersions.length == 0) { + return NameSquattingActionResultJson.failure( + namespaceName, + extensionName, + "Extension has no active versions left to deactivate"); + } + + try { + var result = admins.deleteExtensionNoWait( + adminUser, + extension.getNamespace().getName(), + extension.getName(), + targetVersions); + if (result != null && result.getError() != null) { + return NameSquattingActionResultJson.failure(namespaceName, extensionName, result.getError()); + } + + var message = String.format( + "Deactivated %d version%s of extension %s.%s flagged for name squatting", + targetVersions.length, + targetVersions.length == 1 ? "" : "s", + extension.getNamespace().getName(), + extension.getName()); + logs.logAction(adminUser, ResultJson.success(message)); + + return NameSquattingActionResultJson.success(namespaceName, extensionName, message); + } catch (ErrorResultException exc) { + return NameSquattingActionResultJson.failure(namespaceName, extensionName, exc.getMessage()); + } + } + + /** + * Build the response row for one {@code /} key, or null when its findings + * were cleared between listing the keys and reading them back. + */ + private @Nullable NameSquattingFlagJson toFlagJson( + String key, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo + ) { + var separator = key.indexOf('/'); + if (separator < 0) { + return null; + } + var namespaceName = key.substring(0, separator); + var extensionName = key.substring(separator + 1); + + var failures = repositories.findValidationFailures( + CHECK_TYPE, + namespaceName, + extensionName, + detectedFrom, + detectedTo); + if (failures.isEmpty()) { + return null; + } + + // Failures come back newest first, so the first one carries the most recent metadata. + var latestScan = failures.getFirst().getScan(); + var extension = repositories.findExtension(extensionName, namespaceName); + + var json = new NameSquattingFlagJson(); + json.setNamespace(latestScan.getNamespaceName()); + json.setExtensionName(latestScan.getExtensionName()); + json.setDisplayName( + latestScan.getExtensionDisplayName() != null + ? latestScan.getExtensionDisplayName() + : latestScan.getExtensionName()); + json.setPublisher(latestScan.getPublisher()); + json.setPublisherUrl(latestScan.getPublisherUrl()); + json.setFindingCount(failures.size()); + json.setDateLastDetected(TimeUtil.toUTCString(failures.getFirst().getDetectedAt())); + json.setDateFirstDetected(TimeUtil.toUTCString(failures.getLast().getDetectedAt())); + json.setFindings(failures.stream().map(this::toFindingJson).toList()); + + if (extension == null) { + json.setState(STATE_REJECTED); + json.setActiveVersionCount(0); + } else { + var activeVersions = (int) repositories.findActiveVersions(extension).stream().count(); + json.setActiveVersionCount(activeVersions); + json.setState(extension.isActive() && activeVersions > 0 ? STATE_PUBLISHED : STATE_DEACTIVATED); + } + + return json; + } + + private NameSquattingFindingJson toFindingJson(ExtensionValidationFailure failure) { + var scan = failure.getScan(); + var json = new NameSquattingFindingJson(); + json.setId(String.valueOf(failure.getId())); + json.setScanId(String.valueOf(scan.getId())); + json.setVersion(scan.getExtensionVersion()); + json.setTargetPlatform(scan.getTargetPlatform()); + json.setScanStatus(formatScanStatus(scan.getStatus())); + json.setRuleName(failure.getRuleName()); + json.setReason(failure.getValidationFailureReason()); + json.setDateDetected(TimeUtil.toUTCString(failure.getDetectedAt())); + json.setEnforcedFlag(failure.isEnforced()); + return json; + } + + private TargetPlatformVersion[] activeTargetVersions(Extension extension) { + return repositories.findActiveVersions(extension).stream() + .map(version -> new TargetPlatformVersion(version.getTargetPlatform(), version.getVersion())) + .distinct() + .toArray(TargetPlatformVersion[]::new); + } + + private int countWithState( + @Nullable String namespace, + @Nullable String publisher, + @Nullable String name, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo, + @Nullable ExtensionStateFilter stateFilter + ) { + return (int) repositories.countFlaggedExtensions( + CHECK_TYPE, + namespace, + publisher, + name, + detectedFrom, + detectedTo, + stateFilter); + } + + private List requireTargets(NameSquattingActionRequest request) { + var targets = request.getTargets(); + if (targets == null || targets.isEmpty()) { + throw new ErrorResultException("At least one extension is required", HttpStatus.BAD_REQUEST); + } + for (var target : targets) { + if (target == null + || target.getNamespace() == null || target.getNamespace().isBlank() + || target.getExtension() == null || target.getExtension().isBlank()) { + throw new ErrorResultException( + "Each extension must have a namespace and an extension name", + HttpStatus.BAD_REQUEST); + } + } + return targets; + } + + private NameSquattingActionResponseJson toActionResponse(List results) { + var successful = (int) results.stream().filter(NameSquattingActionResultJson::isSuccess).count(); + + var response = new NameSquattingActionResponseJson(); + response.setProcessed(results.size()); + response.setSuccessful(successful); + response.setFailed(results.size() - successful); + response.setResults(results); + return response; + } + + /** + * Which extensions to include, by what became of them after the check ran. All false means no + * filtering; see {@code NameSquattingFlagJson.state} for what each state means. + */ + public record ExtensionStateFilter( + boolean filterPublished, + boolean filterDeactivated, + boolean filterRejected + ) { + public boolean hasFilter() { + return filterPublished || filterDeactivated || filterRejected; + } + } + + private @Nullable ExtensionStateFilter parseStateFilter(@Nullable List state) { + if (state == null || state.isEmpty()) { + return null; + } + + var published = false; + var deactivated = false; + var rejected = false; + for (var raw : state) { + if (raw == null || raw.isBlank()) { + continue; + } + for (var token : raw.split(",")) { + if (token.isBlank()) { + continue; + } + switch (token.trim().toUpperCase(Locale.ROOT)) { + case STATE_PUBLISHED -> published = true; + case STATE_DEACTIVATED -> deactivated = true; + case STATE_REJECTED -> rejected = true; + default -> throw new ErrorResultException( + "Unknown state filter: " + token.trim(), + HttpStatus.BAD_REQUEST); + } + } + } + + var filter = new ExtensionStateFilter(published, deactivated, rejected); + return filter.hasFilter() ? filter : null; + } + + private @Nullable String normalizeSearch(@Nullable String search) { + if (search == null || search.isBlank()) { + return null; + } + return search.trim(); + } + + private boolean normalizeSortOrder(@Nullable String sortOrder) { + if (sortOrder == null) { + return false; + } + return switch (sortOrder.toLowerCase(Locale.ROOT)) { + case "asc" -> true; + case "desc" -> false; + default -> + throw new ErrorResultException("Unsupported sortOrder value: " + sortOrder, HttpStatus.BAD_REQUEST); + }; + } + + private @Nullable LocalDateTime parseUtcDateTime(@Nullable String raw, String paramName) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return TimeUtil.fromUTCString(raw); + } catch (Exception e) { + throw new ErrorResultException( + "Invalid ISO date-time for parameter '" + paramName + "': " + raw, + HttpStatus.BAD_REQUEST); + } + } + + /** + * Formats the scan status the same way the scan API does, so the admin dashboard shows one + * vocabulary across both views. + */ + private String formatScanStatus(ScanStatus status) { + return switch (status) { + case STARTED -> "STARTED"; + case VALIDATING -> "VALIDATING"; + case SCANNING -> "SCANNING"; + case PASSED -> "PASSED"; + case QUARANTINED -> "QUARANTINED"; + case REJECTED -> "AUTO REJECTED"; + case ERRORED -> "ERROR"; + }; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionRequest.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionRequest.java new file mode 100644 index 000000000..03b6316e3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionRequest.java @@ -0,0 +1,42 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Request body for the name squatting moderation actions. Pass a single target to moderate one + * extension, or several for a bulk operation. + */ +@Schema( + name = "NameSquattingActionRequest", + description = "Extensions a name squatting moderation action should be applied to" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingActionRequest { + + @Schema(description = "Extensions to apply the action to") + private List targets; + + public List getTargets() { + return targets; + } + + public void setTargets(List targets) { + this.targets = targets; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResponseJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResponseJson.java new file mode 100644 index 000000000..293abb0fd --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResponseJson.java @@ -0,0 +1,75 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Response for a name squatting moderation action. Reports per-extension outcomes so that a bulk + * request can partly succeed. + */ +@Schema( + name = "NameSquattingActionResponse", + description = "Result of a name squatting moderation action" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingActionResponseJson extends ResultJson { + + @Schema(description = "Total number of extensions processed") + private int processed; + + @Schema(description = "Number of extensions the action was applied to") + private int successful; + + @Schema(description = "Number of extensions the action could not be applied to") + private int failed; + + @Schema(description = "Detailed result for each extension") + private List results; + + public int getProcessed() { + return processed; + } + + public void setProcessed(int processed) { + this.processed = processed; + } + + public int getSuccessful() { + return successful; + } + + public void setSuccessful(int successful) { + this.successful = successful; + } + + public int getFailed() { + return failed; + } + + public void setFailed(int failed) { + this.failed = failed; + } + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResultJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResultJson.java new file mode 100644 index 000000000..1111d26ac --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingActionResultJson.java @@ -0,0 +1,101 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Outcome of a name squatting moderation action for one extension. + */ +@Schema( + name = "NameSquattingActionResult", + description = "Individual result in a name squatting moderation response" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingActionResultJson { + + @Schema(description = "Namespace of the extension that was processed") + private String namespace; + + @Schema(description = "Name of the extension that was processed") + private String extension; + + @Schema(description = "Whether the action was applied successfully") + private boolean success; + + @Schema(description = "What the action changed, when it succeeded") + private String message; + + @Schema(description = "Why the action could not be applied, when it failed") + private String error; + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtension() { + return extension; + } + + public void setExtension(String extension) { + this.extension = extension; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public static NameSquattingActionResultJson success(String namespace, String extension, String message) { + var result = new NameSquattingActionResultJson(); + result.setNamespace(namespace); + result.setExtension(extension); + result.setSuccess(true); + result.setMessage(message); + return result; + } + + public static NameSquattingActionResultJson failure(String namespace, String extension, String error) { + var result = new NameSquattingActionResultJson(); + result.setNamespace(namespace); + result.setExtension(extension); + result.setSuccess(false); + result.setError(error); + return result; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingCountsJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingCountsJson.java new file mode 100644 index 000000000..8c2d6aed0 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingCountsJson.java @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Counts of extensions flagged by the name squatting publisher check, broken down by what became + * of the extension after the check ran. + */ +@Schema( + name = "NameSquattingCounts", + description = "Counts of extensions flagged by the name squatting publisher check" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingCountsJson extends ResultJson { + + @Schema(description = "Total number of flagged extensions") + private int total; + + @Schema(description = "Flagged extensions that are live and can be moderated") + private int published; + + @Schema(description = "Flagged extensions that exist but have already been deactivated") + private int deactivated; + + @Schema(description = "Flagged extensions whose publication was blocked, so nothing was created") + private int rejected; + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public int getPublished() { + return published; + } + + public void setPublished(int published) { + this.published = published; + } + + public int getDeactivated() { + return deactivated; + } + + public void setDeactivated(int deactivated) { + this.deactivated = deactivated; + } + + public int getRejected() { + return rejected; + } + + public void setRejected(int rejected) { + this.rejected = rejected; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFindingJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFindingJson.java new file mode 100644 index 000000000..ba56148a6 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFindingJson.java @@ -0,0 +1,127 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * A single name squatting finding, together with the version whose publication triggered it. + */ +@Schema( + name = "NameSquattingFinding", + description = "One name squatting check failure recorded for an extension version" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingFindingJson { + + @Schema(description = "Identifier of the recorded validation failure") + private String id; + + @Schema(description = "Identifier of the scan that recorded this finding") + private String scanId; + + @Schema(description = "Version whose publication triggered the check") + private String version; + + @Schema(description = "Target platform of that version") + private String targetPlatform; + + @Schema(description = "Status of the scan that recorded this finding") + private String scanStatus; + + @Schema(description = "Name of the rule that flagged the extension") + private String ruleName; + + @Schema(description = "Explanation of why the extension was flagged") + private String reason; + + @Schema(description = "When the check failed (UTC)") + private String dateDetected; + + @Schema(description = "Whether the failure blocked publication when it was detected") + private boolean enforcedFlag; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getScanId() { + return scanId; + } + + public void setScanId(String scanId) { + this.scanId = scanId; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getTargetPlatform() { + return targetPlatform; + } + + public void setTargetPlatform(String targetPlatform) { + this.targetPlatform = targetPlatform; + } + + public String getScanStatus() { + return scanStatus; + } + + public void setScanStatus(String scanStatus) { + this.scanStatus = scanStatus; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getDateDetected() { + return dateDetected; + } + + public void setDateDetected(String dateDetected) { + this.dateDetected = dateDetected; + } + + public boolean isEnforcedFlag() { + return enforcedFlag; + } + + public void setEnforcedFlag(boolean enforcedFlag) { + this.enforcedFlag = enforcedFlag; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagJson.java new file mode 100644 index 000000000..508beb3d2 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagJson.java @@ -0,0 +1,160 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * An extension flagged by the name squatting publisher check, with every finding recorded for it. + *

+ * Findings are grouped per extension rather than per version, because both moderation decisions an + * administrator can take - clearing the check as a false positive and deactivating the extension - + * apply to the extension as a whole. + */ +@Schema( + name = "NameSquattingFlag", + description = "An extension flagged by the name squatting publisher check" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingFlagJson { + + @Schema(description = "Namespace of the flagged extension") + private String namespace; + + @Schema(description = "Name of the flagged extension") + private String extensionName; + + @Schema(description = "Display name taken from the most recent flagged version") + private String displayName; + + @Schema(description = "Publisher who uploaded the most recent flagged version") + private String publisher; + + @Schema(description = "Profile URL of that publisher") + private String publisherUrl; + + @Schema( + description = "What became of the extension after the check ran: PUBLISHED when it is live, " + + "DEACTIVATED when it exists but all versions are inactive, and REJECTED when " + + "publication was blocked so the extension was never created", + allowableValues = { "PUBLISHED", "DEACTIVATED", "REJECTED" } + ) + private String state; + + @Schema(description = "Number of active versions the extension currently has") + private int activeVersionCount; + + @Schema(description = "Number of name squatting findings recorded for the extension") + private int findingCount; + + @Schema(description = "When the extension was first flagged (UTC)") + private String dateFirstDetected; + + @Schema(description = "When the extension was most recently flagged (UTC)") + private String dateLastDetected; + + @Schema(description = "The individual findings, most recent first") + private List findings; + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getPublisher() { + return publisher; + } + + public void setPublisher(String publisher) { + this.publisher = publisher; + } + + public String getPublisherUrl() { + return publisherUrl; + } + + public void setPublisherUrl(String publisherUrl) { + this.publisherUrl = publisherUrl; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public int getActiveVersionCount() { + return activeVersionCount; + } + + public void setActiveVersionCount(int activeVersionCount) { + this.activeVersionCount = activeVersionCount; + } + + public int getFindingCount() { + return findingCount; + } + + public void setFindingCount(int findingCount) { + this.findingCount = findingCount; + } + + public String getDateFirstDetected() { + return dateFirstDetected; + } + + public void setDateFirstDetected(String dateFirstDetected) { + this.dateFirstDetected = dateFirstDetected; + } + + public String getDateLastDetected() { + return dateLastDetected; + } + + public void setDateLastDetected(String dateLastDetected) { + this.dateLastDetected = dateLastDetected; + } + + public List getFindings() { + return findings; + } + + public void setFindings(List findings) { + this.findings = findings; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagListJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagListJson.java new file mode 100644 index 000000000..f2fcfe42a --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingFlagListJson.java @@ -0,0 +1,76 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * Paginated list of extensions flagged by the name squatting publisher check. + */ +@Schema( + name = "NameSquattingFlagList", + description = "Paginated list of extensions flagged by the name squatting publisher check" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingFlagListJson extends ResultJson { + + public static NameSquattingFlagListJson error(String message) { + var result = new NameSquattingFlagListJson(); + result.setError(message); + return result; + } + + @Schema(description = "Number of skipped entries") + @NotNull + @Min(0) + private int offset; + + @Schema(description = "Total number of matching extensions") + @NotNull + @Min(0) + private int totalSize; + + @Schema(description = "Current page of flagged extensions") + @NotNull + private List flags; + + public int getOffset() { + return offset; + } + + public void setOffset(int offset) { + this.offset = offset; + } + + public int getTotalSize() { + return totalSize; + } + + public void setTotalSize(int totalSize) { + this.totalSize = totalSize; + } + + public List getFlags() { + return flags; + } + + public void setFlags(List flags) { + this.flags = flags; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/NameSquattingTargetJson.java b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingTargetJson.java new file mode 100644 index 000000000..3567c45ca --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/NameSquattingTargetJson.java @@ -0,0 +1,53 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Identifies one extension a moderation action applies to. + *

+ * Namespace and extension name are kept as separate fields rather than a single qualified id + * because extension names may contain dots. + */ +@Schema( + name = "NameSquattingTarget", + description = "The extension a name squatting moderation action applies to" +) +@JsonInclude(Include.NON_NULL) +public class NameSquattingTargetJson { + + @Schema(description = "Namespace of the extension", example = "julialang") + private String namespace; + + @Schema(description = "Name of the extension", example = "language-julia") + private String extension; + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtension() { + return extension; + } + + public void setExtension(String extension) { + this.extension = extension; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java index d2896e5fe..eac383553 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java @@ -15,8 +15,12 @@ import java.time.LocalDateTime; import java.util.List; +import jakarta.transaction.Transactional; +import org.jspecify.annotations.Nullable; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import org.springframework.data.util.Streamable; import org.eclipse.openvsx.entities.ExtensionScan; @@ -77,4 +81,179 @@ public interface ExtensionValidationFailureRepository extends Repository findDistinctCheckTypes(); + + /** + * Find the extensions that have at least one failure of the given check type, one entry per + * extension rather than one per failure, for the moderation views in the admin dashboard. + *

+ * Each entry is returned as {@code /}, both lower-cased. Namespace and + * extension names are URL path segments, so neither can contain a slash and the key is + * unambiguous. Callers resolve the individual failures per key. + *

+ * Extensions are ordered by their most recent detection (newest first unless {@code ascending}), + * with the name as a tie-breaker so that paging stays stable. + *

+ * The state filter selects extensions by what became of them after the check ran: {@code + * filterPublished} keeps extensions that exist and are active, {@code filterDeactivated} those + * that exist but have been deactivated, and {@code filterRejected} those that never made it + * into the registry because publication was blocked. Pass {@code applyStateFilter = false} to + * keep all of them. + */ + @Query( + value = """ + SELECT LOWER(s.namespace_name) || '/' || LOWER(s.extension_name) AS extension_key + FROM extension_validation_failure f + JOIN extension_scan s ON s.id = f.scan_id + WHERE f.validation_type = :checkType + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(s.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(s.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(s.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(s.extension_display_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:detectedFrom AS TIMESTAMP) IS NULL OR f.detected_at >= :detectedFrom) + AND (CAST(:detectedTo AS TIMESTAMP) IS NULL OR f.detected_at <= :detectedTo) + AND (:applyStateFilter = false + OR (:filterPublished = true AND EXISTS ( + SELECT 1 FROM extension e JOIN namespace n ON n.id = e.namespace_id + WHERE LOWER(e.name) = LOWER(s.extension_name) + AND LOWER(n.name) = LOWER(s.namespace_name) + AND e.active = true)) + OR (:filterDeactivated = true AND EXISTS ( + SELECT 1 FROM extension e JOIN namespace n ON n.id = e.namespace_id + WHERE LOWER(e.name) = LOWER(s.extension_name) + AND LOWER(n.name) = LOWER(s.namespace_name) + AND e.active = false)) + OR (:filterRejected = true AND NOT EXISTS ( + SELECT 1 FROM extension e JOIN namespace n ON n.id = e.namespace_id + WHERE LOWER(e.name) = LOWER(s.extension_name) + AND LOWER(n.name) = LOWER(s.namespace_name)))) + GROUP BY LOWER(s.namespace_name), LOWER(s.extension_name) + ORDER BY CASE WHEN CAST(:ascending AS BOOLEAN) = true THEN MAX(f.detected_at) END ASC, + CASE WHEN CAST(:ascending AS BOOLEAN) = false THEN MAX(f.detected_at) END DESC, + LOWER(s.namespace_name), LOWER(s.extension_name) + LIMIT :limit OFFSET :offset + """, + nativeQuery = true + ) + List findFlaggedExtensionKeys( + @Param("checkType") String checkType, + @Nullable + @Param("namespace") String namespace, + @Nullable + @Param("publisher") String publisher, + @Nullable + @Param("name") String name, + @Nullable + @Param("detectedFrom") LocalDateTime detectedFrom, + @Nullable + @Param("detectedTo") LocalDateTime detectedTo, + @Param("applyStateFilter") boolean applyStateFilter, + @Param("filterPublished") boolean filterPublished, + @Param("filterDeactivated") boolean filterDeactivated, + @Param("filterRejected") boolean filterRejected, + @Param("ascending") boolean ascending, + @Param("limit") int limit, + @Param("offset") int offset + ); + + /** + * Count the extensions matched by {@link #findFlaggedExtensionKeys}, so the admin dashboard can + * page through them and show totals per state. + */ + @Query( + value = """ + SELECT COUNT(*) FROM ( + SELECT 1 + FROM extension_validation_failure f + JOIN extension_scan s ON s.id = f.scan_id + WHERE f.validation_type = :checkType + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(s.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(s.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(s.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(s.extension_display_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:detectedFrom AS TIMESTAMP) IS NULL OR f.detected_at >= :detectedFrom) + AND (CAST(:detectedTo AS TIMESTAMP) IS NULL OR f.detected_at <= :detectedTo) + AND (:applyStateFilter = false + OR (:filterPublished = true AND EXISTS ( + SELECT 1 FROM extension e JOIN namespace n ON n.id = e.namespace_id + WHERE LOWER(e.name) = LOWER(s.extension_name) + AND LOWER(n.name) = LOWER(s.namespace_name) + AND e.active = true)) + OR (:filterDeactivated = true AND EXISTS ( + SELECT 1 FROM extension e JOIN namespace n ON n.id = e.namespace_id + WHERE LOWER(e.name) = LOWER(s.extension_name) + AND LOWER(n.name) = LOWER(s.namespace_name) + AND e.active = false)) + OR (:filterRejected = true AND NOT EXISTS ( + SELECT 1 FROM extension e JOIN namespace n ON n.id = e.namespace_id + WHERE LOWER(e.name) = LOWER(s.extension_name) + AND LOWER(n.name) = LOWER(s.namespace_name)))) + GROUP BY LOWER(s.namespace_name), LOWER(s.extension_name) + ) flagged + """, + nativeQuery = true + ) + long countFlaggedExtensions( + @Param("checkType") String checkType, + @Nullable + @Param("namespace") String namespace, + @Nullable + @Param("publisher") String publisher, + @Nullable + @Param("name") String name, + @Nullable + @Param("detectedFrom") LocalDateTime detectedFrom, + @Nullable + @Param("detectedTo") LocalDateTime detectedTo, + @Param("applyStateFilter") boolean applyStateFilter, + @Param("filterPublished") boolean filterPublished, + @Param("filterDeactivated") boolean filterDeactivated, + @Param("filterRejected") boolean filterRejected + ); + + /** + * Find all failures of the given check type for one extension, newest first. + *

+ * Namespace and extension name must be passed lower-cased. The date range narrows the failures + * the same way {@link #findFlaggedExtensionKeys} narrows the extensions, so that what a caller + * lists for an extension matches why the extension was listed at all. + */ + @Query(""" + select f from ExtensionValidationFailure f join fetch f.scan s + where f.checkType = :checkType + and lower(s.namespaceName) = :namespace + and lower(s.extensionName) = :extension + and (cast(:detectedFrom as LocalDateTime) is null or f.detectedAt >= :detectedFrom) + and (cast(:detectedTo as LocalDateTime) is null or f.detectedAt <= :detectedTo) + order by f.detectedAt desc, f.id desc + """) + List findByCheckTypeAndExtension( + @Param("checkType") String checkType, + @Param("namespace") String namespace, + @Param("extension") String extension, + @Nullable + @Param("detectedFrom") LocalDateTime detectedFrom, + @Nullable + @Param("detectedTo") LocalDateTime detectedTo + ); + + /** + * Delete all failures of the given check type recorded for one extension and return how many + * rows were removed. Used to clear a check that an administrator judged a false positive. + *

+ * Namespace and extension name must be passed lower-cased. + */ + @Modifying + @Transactional + @Query(""" + delete from ExtensionValidationFailure f + where f.checkType = :checkType + and f.scan in (select s from ExtensionScan s + where lower(s.namespaceName) = :namespace + and lower(s.extensionName) = :extension) + """) + int deleteByCheckTypeAndExtension( + @Param("checkType") String checkType, + @Param("namespace") String namespace, + @Param("extension") String extension + ); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index aa77b4e5d..ae5d00505 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -14,6 +14,7 @@ import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -1401,6 +1402,100 @@ public boolean hasValidationFailuresOfType(ExtensionScan scan, String checkType) return extensionValidationFailureRepo.existsByScanAndCheckType(scan, checkType); } + /** + * Find one page of the extensions that failed the given check, as {@code /} + * keys ordered by their most recent detection. See + * {@link ExtensionValidationFailureRepository#findFlaggedExtensionKeys}. + */ + public List findFlaggedExtensionKeys( + String checkType, + @Nullable String namespace, + @Nullable String publisher, + @Nullable String name, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo, + org.eclipse.openvsx.admin.NameSquattingAPI.@Nullable ExtensionStateFilter stateFilter, + boolean ascending, + int limit, + int offset + ) { + var applyStateFilter = stateFilter != null && stateFilter.hasFilter(); + return extensionValidationFailureRepo.findFlaggedExtensionKeys( + checkType, + blankToNull(namespace), + blankToNull(publisher), + blankToNull(name), + detectedFrom, + detectedTo, + applyStateFilter, + stateFilter != null && stateFilter.filterPublished(), + stateFilter != null && stateFilter.filterDeactivated(), + stateFilter != null && stateFilter.filterRejected(), + ascending, + limit, + offset); + } + + /** + * Count the extensions matched by {@link #findFlaggedExtensionKeys} with the same filters. + */ + public long countFlaggedExtensions( + String checkType, + @Nullable String namespace, + @Nullable String publisher, + @Nullable String name, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo, + org.eclipse.openvsx.admin.NameSquattingAPI.@Nullable ExtensionStateFilter stateFilter + ) { + var applyStateFilter = stateFilter != null && stateFilter.hasFilter(); + return extensionValidationFailureRepo.countFlaggedExtensions( + checkType, + blankToNull(namespace), + blankToNull(publisher), + blankToNull(name), + detectedFrom, + detectedTo, + applyStateFilter, + stateFilter != null && stateFilter.filterPublished(), + stateFilter != null && stateFilter.filterDeactivated(), + stateFilter != null && stateFilter.filterRejected()); + } + + /** + * Find all failures of the given check type recorded for one extension, newest first. + * Namespace and extension name are matched case-insensitively. + */ + public List findValidationFailures( + String checkType, + String namespaceName, + String extensionName, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo + ) { + return extensionValidationFailureRepo.findByCheckTypeAndExtension( + checkType, + namespaceName.toLowerCase(Locale.ROOT), + extensionName.toLowerCase(Locale.ROOT), + detectedFrom, + detectedTo); + } + + /** + * Delete all failures of the given check type recorded for one extension and return how many + * rows were removed. Namespace and extension name are matched case-insensitively. + */ + public int deleteValidationFailures(String checkType, String namespaceName, String extensionName) { + return extensionValidationFailureRepo.deleteByCheckTypeAndExtension( + checkType, + namespaceName.toLowerCase(Locale.ROOT), + extensionName.toLowerCase(Locale.ROOT)); + } + + private static @Nullable String blankToNull(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + public AdminScanDecision saveAdminScanDecision(AdminScanDecision decision) { return adminScanDecisionRepo.save(decision); } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java new file mode 100644 index 000000000..c2e0861cf --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java @@ -0,0 +1,542 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.admin; + +import java.time.LocalDateTime; +import java.util.List; + +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.data.util.Streamable; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.TargetPlatformVersion; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + value = NameSquattingAPI.class, + excludeAutoConfiguration = { OAuth2ClientWebSecurityAutoConfiguration.class } +) +@AutoConfigureMockMvc(addFilters = false) +class NameSquattingAPITest { + + private static final String CHECK_TYPE = "NAME_SQUATTING"; + + private static final LocalDateTime FIRST_DETECTED = LocalDateTime.of(2026, 1, 5, 9, 30); + + private static final LocalDateTime LAST_DETECTED = LocalDateTime.of(2026, 2, 11, 14, 0); + + @Autowired + MockMvc mockMvc; + + @MockitoBean + RepositoryService repositories; + + @MockitoBean + AdminService admins; + + @MockitoBean + LogService logs; + + @MockitoBean + MeterRegistry meterRegistry; + + @Test + void getFlaggedExtensions_groups_findings_per_extension() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + stubFlaggedExtension("ns", "ext"); + + var extension = TestData.extension("ns", "ext", true); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)) + .thenReturn(Streamable.of(TestData.version("universal", "1.0.0"))); + + mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(3)) + .andExpect(jsonPath("$.offset").value(0)) + .andExpect(jsonPath("$.flags.length()").value(1)) + .andExpect(jsonPath("$.flags[0].namespace").value("ns")) + .andExpect(jsonPath("$.flags[0].extensionName").value("ext")) + .andExpect(jsonPath("$.flags[0].displayName").value("Extension Display Name")) + .andExpect(jsonPath("$.flags[0].publisher").value("publisher")) + .andExpect(jsonPath("$.flags[0].state").value("PUBLISHED")) + .andExpect(jsonPath("$.flags[0].activeVersionCount").value(1)) + .andExpect(jsonPath("$.flags[0].findingCount").value(2)) + .andExpect(jsonPath("$.flags[0].findings.length()").value(2)) + // Findings come back newest first, so the dates bracket the whole history. + .andExpect(jsonPath("$.flags[0].findings[0].version").value("2.0.0")) + .andExpect(jsonPath("$.flags[0].findings[0].scanStatus").value("PASSED")) + .andExpect(jsonPath("$.flags[0].findings[0].enforcedFlag").value(false)) + .andExpect(jsonPath("$.flags[0].findings[1].version").value("1.0.0")) + .andExpect(jsonPath("$.flags[0].dateLastDetected").value("2026-02-11T14:00Z")) + .andExpect(jsonPath("$.flags[0].dateFirstDetected").value("2026-01-05T09:30Z")); + } + + @Test + void getFlaggedExtensions_reports_rejected_when_extension_was_never_created() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + stubFlaggedExtension("ns", "ext"); + + // Publication was blocked by an enforced check, so no extension exists. + when(repositories.findExtension("ext", "ns")).thenReturn(null); + + mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flags[0].state").value("REJECTED")) + .andExpect(jsonPath("$.flags[0].activeVersionCount").value(0)); + } + + @Test + void getFlaggedExtensions_reports_deactivated_when_no_active_versions_remain() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + stubFlaggedExtension("ns", "ext"); + + var extension = TestData.extension("ns", "ext", false); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)).thenReturn(Streamable.empty()); + + mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flags[0].state").value("DEACTIVATED")) + .andExpect(jsonPath("$.flags[0].activeVersionCount").value(0)); + } + + @Test + void getFlaggedExtensions_validates_paging_parameters() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform( + get("/admin/name-squatting") + .param("size", "-1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform( + get("/admin/name-squatting") + .param("size", "101") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be larger than 100")); + + mockMvc.perform( + get("/admin/name-squatting") + .param("offset", "-1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } + + @Test + void getFlaggedExtensions_rejects_unknown_state_filter() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform( + get("/admin/name-squatting") + .param("state", "PUBLISHED,SOMETHING") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Unknown state filter: SOMETHING")); + } + + @Test + void getFlaggedExtensions_rejects_unknown_sort_order() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform( + get("/admin/name-squatting") + .param("sortOrder", "sideways") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Unsupported sortOrder value: sideways")); + } + + @Test + void getCounts_breaks_down_flagged_extensions_by_state() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + when(repositories.countFlaggedExtensions(eq(CHECK_TYPE), any(), any(), any(), any(), any(), eq(null))) + .thenReturn(9L); + when( + repositories.countFlaggedExtensions( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + eq(new NameSquattingAPI.ExtensionStateFilter(true, false, false)))) + .thenReturn(4L); + when( + repositories.countFlaggedExtensions( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + eq(new NameSquattingAPI.ExtensionStateFilter(false, true, false)))) + .thenReturn(2L); + when( + repositories.countFlaggedExtensions( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + eq(new NameSquattingAPI.ExtensionStateFilter(false, false, true)))) + .thenReturn(3L); + + mockMvc.perform(get("/admin/name-squatting/counts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(9)) + .andExpect(jsonPath("$.published").value(4)) + .andExpect(jsonPath("$.deactivated").value(2)) + .andExpect(jsonPath("$.rejected").value(3)); + } + + @Test + void clearFindings_removes_the_records_and_logs_the_action() throws Exception { + var adminUser = TestData.adminUser(); + when(admins.checkAdminUser()).thenReturn(adminUser); + when(repositories.deleteValidationFailures(CHECK_TYPE, "ns", "ext")).thenReturn(2); + + mockMvc.perform( + post("/admin/name-squatting/clear") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.processed").value(1)) + .andExpect(jsonPath("$.successful").value(1)) + .andExpect(jsonPath("$.failed").value(0)) + .andExpect(jsonPath("$.results[0].success").value(true)) + .andExpect( + jsonPath("$.results[0].message") + .value("Cleared 2 name squatting findings for extension ns.ext as a false positive")); + + verify(repositories).deleteValidationFailures(CHECK_TYPE, "ns", "ext"); + verify(logs).logAction(eq(adminUser), any(ResultJson.class)); + } + + @Test + void clearFindings_reports_a_failure_when_nothing_is_recorded() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(repositories.deleteValidationFailures(CHECK_TYPE, "ns", "ext")).thenReturn(0); + + mockMvc.perform( + post("/admin/name-squatting/clear") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successful").value(0)) + .andExpect(jsonPath("$.failed").value(1)) + .andExpect(jsonPath("$.results[0].success").value(false)) + .andExpect( + jsonPath("$.results[0].error") + .value("No name squatting findings are recorded for this extension")); + + verify(logs, never()).logAction(any(), any()); + } + + @Test + void clearFindings_requires_at_least_one_extension() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform( + post("/admin/name-squatting/clear") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("At least one extension is required")); + } + + @Test + void clearFindings_rejects_targets_without_a_name() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform( + post("/admin/name-squatting/clear") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath("$.error").value("Each extension must have a namespace and an extension name")); + } + + @Test + void deleteExtensions_deactivates_every_active_version() throws Exception { + var adminUser = TestData.adminUser(); + when(admins.checkAdminUser()).thenReturn(adminUser); + + var extension = TestData.extension("ns", "ext", true); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)) + .thenReturn( + Streamable.of( + TestData.version("universal", "1.0.0"), + TestData.version("linux-x64", "2.0.0"))); + when(admins.deleteExtensionNoWait(any(), anyString(), anyString(), any(TargetPlatformVersion[].class))) + .thenReturn(ResultJson.success("deleted")); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successful").value(1)) + .andExpect(jsonPath("$.results[0].success").value(true)) + .andExpect( + jsonPath("$.results[0].message") + .value("Deactivated 2 versions of extension ns.ext flagged for name squatting")); + + verify(admins).deleteExtensionNoWait( + eq(adminUser), + eq("ns"), + eq("ext"), + eq(new TargetPlatformVersion("universal", "1.0.0")), + eq(new TargetPlatformVersion("linux-x64", "2.0.0"))); + verify(logs).logAction(eq(adminUser), any(ResultJson.class)); + } + + @Test + void deleteExtensions_refuses_an_extension_that_was_never_created() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(repositories.findExtension("ext", "ns")).thenReturn(null); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successful").value(0)) + .andExpect(jsonPath("$.failed").value(1)) + .andExpect( + jsonPath("$.results[0].error") + .value("Extension does not exist, its publication was blocked by the check")); + + verify(admins, never()).deleteExtensionNoWait(any(), anyString(), anyString()); + } + + @Test + void deleteExtensions_refuses_an_extension_that_is_already_deactivated() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var extension = TestData.extension("ns", "ext", false); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)).thenReturn(Streamable.empty()); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.failed").value(1)) + .andExpect( + jsonPath("$.results[0].error") + .value("Extension has no active versions left to deactivate")); + } + + @Test + void deleteExtensions_reports_why_a_deletion_was_refused() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var extension = TestData.extension("ns", "ext", true); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)) + .thenReturn(Streamable.of(TestData.version("universal", "1.0.0"))); + when(admins.deleteExtensionNoWait(any(), anyString(), anyString(), any(TargetPlatformVersion[].class))) + .thenThrow( + new ErrorResultException( + "Extension is bundled by other extensions", + HttpStatus.BAD_REQUEST)); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.failed").value(1)) + .andExpect(jsonPath("$.results[0].error").value("Extension is bundled by other extensions")); + } + + @Test + void getFlaggedExtensions_requires_admin() throws Exception { + when(admins.checkAdminUser()) + .thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + + mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void getCounts_requires_admin() throws Exception { + when(admins.checkAdminUser()) + .thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + + mockMvc.perform(get("/admin/name-squatting/counts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void moderation_actions_require_admin() throws Exception { + when(admins.checkAdminUser()) + .thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + + mockMvc.perform( + post("/admin/name-squatting/clear") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + verify(repositories, never()).deleteValidationFailures(anyString(), anyString(), anyString()); + } + + /** + * Stub one flagged extension with two findings, newest first, as the repository returns them. + */ + private void stubFlaggedExtension(String namespace, String extensionName) { + when( + repositories.findFlaggedExtensionKeys( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + any(), + anyBoolean(), + anyInt(), + anyInt())) + .thenReturn(List.of(namespace + "/" + extensionName)); + when(repositories.countFlaggedExtensions(eq(CHECK_TYPE), any(), any(), any(), any(), any(), any())) + .thenReturn(3L); + when(repositories.findValidationFailures(eq(CHECK_TYPE), eq(namespace), eq(extensionName), any(), any())) + .thenReturn( + List.of( + TestData.failure( + 2, + TestData.scan(20, namespace, extensionName, "2.0.0", ScanStatus.PASSED), + LAST_DETECTED, + false), + TestData.failure( + 1, + TestData.scan(10, namespace, extensionName, "1.0.0", ScanStatus.REJECTED), + FIRST_DETECTED, + true))); + } + + private static class TestData { + + static ExtensionScan scan(long id, String namespace, String name, String version, ScanStatus status) { + var scan = new ExtensionScan(); + scan.setId(id); + scan.setNamespaceName(namespace); + scan.setExtensionName(name); + scan.setExtensionDisplayName("Extension Display Name"); + scan.setExtensionVersion(version); + scan.setTargetPlatform("universal"); + scan.setUniversalTargetPlatform(true); + scan.setPublisher("publisher"); + scan.setPublisherUrl("https://example.com/publisher"); + scan.setStartedAt(LocalDateTime.of(2026, 1, 1, 0, 0)); + scan.setStatus(status); + return scan; + } + + static ExtensionValidationFailure failure( + long id, + ExtensionScan scan, + LocalDateTime detectedAt, + boolean enforced + ) { + var failure = ExtensionValidationFailure + .create(CHECK_TYPE, "Levenshtein Distance", "Too similar to an existing extension"); + failure.setId(id); + failure.setScan(scan); + failure.setDetectedAt(detectedAt); + failure.setEnforced(enforced); + return failure; + } + + static Extension extension(String namespaceName, String name, boolean active) { + var namespace = new Namespace(); + namespace.setName(namespaceName); + var extension = new Extension(); + extension.setName(name); + extension.setNamespace(namespace); + extension.setActive(active); + return extension; + } + + static ExtensionVersion version(String targetPlatform, String version) { + var extVersion = new ExtensionVersion(); + extVersion.setTargetPlatform(targetPlatform); + extVersion.setVersion(version); + return extVersion; + } + + static UserData adminUser() { + var user = new UserData(); + user.setRole(UserData.Role.ADMIN); + return user; + } + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index e8bd4e7b3..487a483a5 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -27,6 +27,7 @@ import org.springframework.data.domain.Pageable; import org.eclipse.openvsx.AbstractPostgresContainerTest; +import org.eclipse.openvsx.admin.NameSquattingAPI; import org.eclipse.openvsx.entities.AdminScanDecision; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.DailyUsageStats; @@ -392,8 +393,39 @@ void testExecuteQueries() { () -> repositories.findValidationFailure(validationFailure.getId()), () -> repositories.findDistinctValidationFailureRuleNames(), () -> repositories.findDistinctValidationFailureCheckTypes(), + () -> repositories.findFlaggedExtensionKeys( + validationFailure.getCheckType(), + namespace.getName(), + scan.getPublisher(), + extension.getName(), + NOW, + NOW, + new NameSquattingAPI.ExtensionStateFilter(true, true, true), + false, + 10, + 0), + () -> repositories.countFlaggedExtensions( + validationFailure.getCheckType(), + namespace.getName(), + scan.getPublisher(), + extension.getName(), + NOW, + NOW, + new NameSquattingAPI.ExtensionStateFilter(true, true, true)), + () -> repositories.findValidationFailures( + validationFailure.getCheckType(), + namespace.getName(), + extension.getName(), + NOW, + NOW), () -> repositories.saveExtensionScan(scan), () -> repositories.saveValidationFailure(validationFailure), + // Hibernate flushes before a bulk delete, so this has to run while every entity is + // still persistable - i.e. before the tier and customer deletes further down + () -> repositories.deleteValidationFailures( + validationFailure.getCheckType(), + namespace.getName(), + extension.getName()), // DB paging and filtering methods for scan API () -> repositories.countExtensionScansByStatusAndDateRange(ScanStatus.STARTED, NOW, NOW), () -> repositories diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index c83e70eeb..d9bb501f1 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -2,6 +2,12 @@ This change log covers only the frontend library (webui) of Open VSX. +## [next] + +### Added + +- Add a Name Squatting section to the admin dashboard: it lists the extensions flagged by the name squatting publisher check, grouped per extension, and lets an administrator clear the findings as a false positive or soft delete an extension that is squatting a name + ## [v1.1.2] (20/08/2026) ### Added diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 626a7ba36..23bda4334 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -42,6 +42,11 @@ import { FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, + NameSquattingActionRequest, + NameSquattingActionResponse, + NameSquattingCounts, + NameSquattingFlagList, + NameSquattingState, Tier, TierList, Customer, @@ -680,6 +685,33 @@ export interface AdminService { makeScanDecision(request: ScanDecisionRequest): Promise>; makeFileDecision(request: FileDecisionRequest): Promise>; deleteFileDecisions(request: FileDecisionDeleteRequest): Promise>; + // Name squatting moderation API + getNameSquattingFlags( + abortController: AbortController, + params?: { + size?: number; + offset?: number; + publisher?: string; + namespace?: string; + name?: string; + state?: NameSquattingState[]; + dateDetectedFrom?: string; + dateDetectedTo?: string; + sortOrder?: 'asc' | 'desc'; + } + ): Promise>; + getNameSquattingCounts( + abortController: AbortController, + params?: { + publisher?: string; + namespace?: string; + name?: string; + dateDetectedFrom?: string; + dateDetectedTo?: string; + } + ): Promise>; + clearNameSquattingFlags(request: NameSquattingActionRequest): Promise>; + deleteNameSquattingExtensions(request: NameSquattingActionRequest): Promise>; getTiers(abortController: AbortController): Promise>; createTier(tier: Tier): Promise>; updateTier(name: string, tier: Tier): Promise>; @@ -1189,6 +1221,97 @@ export class AdminServiceImpl implements AdminService { }); } + async getNameSquattingFlags( + abortController: AbortController, + params?: { + size?: number; + offset?: number; + publisher?: string; + namespace?: string; + name?: string; + state?: NameSquattingState[]; + dateDetectedFrom?: string; + dateDetectedTo?: string; + sortOrder?: 'asc' | 'desc'; + } + ): Promise> { + const query: { key: string; value: string | number }[] = []; + if (params) { + if (params.size !== undefined) query.push({ key: 'size', value: params.size }); + if (params.offset !== undefined) query.push({ key: 'offset', value: params.offset }); + if (params.publisher) query.push({ key: 'publisher', value: params.publisher }); + if (params.namespace) query.push({ key: 'namespace', value: params.namespace }); + if (params.name) query.push({ key: 'name', value: params.name }); + if (params.state && params.state.length > 0) query.push({ key: 'state', value: params.state.join(',') }); + if (params.dateDetectedFrom) query.push({ key: 'dateDetectedFrom', value: params.dateDetectedFrom }); + if (params.dateDetectedTo) query.push({ key: 'dateDetectedTo', value: params.dateDetectedTo }); + if (params.sortOrder) query.push({ key: 'sortOrder', value: params.sortOrder }); + } + return sendNonRetriableRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'name-squatting'], query) + }); + } + + async getNameSquattingCounts( + abortController: AbortController, + params?: { + publisher?: string; + namespace?: string; + name?: string; + dateDetectedFrom?: string; + dateDetectedTo?: string; + } + ): Promise> { + const query: { key: string; value: string | number }[] = []; + if (params) { + if (params.publisher) query.push({ key: 'publisher', value: params.publisher }); + if (params.namespace) query.push({ key: 'namespace', value: params.namespace }); + if (params.name) query.push({ key: 'name', value: params.name }); + if (params.dateDetectedFrom) query.push({ key: 'dateDetectedFrom', value: params.dateDetectedFrom }); + if (params.dateDetectedTo) query.push({ key: 'dateDetectedTo', value: params.dateDetectedTo }); + } + return sendNonRetriableRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'name-squatting', 'counts'], query) + }); + } + + async clearNameSquattingFlags(request: NameSquattingActionRequest): Promise> { + return sendNonRetriableRequest({ + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'name-squatting', 'clear']), + headers: await this.jsonMutationHeaders(), + payload: request + }); + } + + async deleteNameSquattingExtensions( + request: NameSquattingActionRequest + ): Promise> { + return sendNonRetriableRequest({ + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'name-squatting', 'delete']), + headers: await this.jsonMutationHeaders(), + payload: request + }); + } + + /** JSON headers for a mutating admin request, carrying the CSRF token when one is available. */ + private async jsonMutationHeaders(): Promise> { + const headers: Record = { 'Content-Type': 'application/json;charset=UTF-8' }; + const csrfResponse = await this.registry.getCsrfToken(); + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return headers; + } + async getTiers(abortController: AbortController): Promise> { return sendNonRetriableRequest({ abortController, diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 8580bcc41..068bb4ce8 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -452,6 +452,81 @@ export interface FileDecisionDeleteResponse { results: FileDecisionDeleteResult[]; } +// Name squatting moderation types (used by the admin name squatting UI) + +/** + * What became of a flagged extension after the name squatting check ran. `PUBLISHED` and + * `DEACTIVATED` extensions exist and can be moderated; `REJECTED` ones were never created because + * the check blocked publication, so there is nothing to act on. + */ +export type NameSquattingState = 'PUBLISHED' | 'DEACTIVATED' | 'REJECTED'; + +export interface NameSquattingFinding { + id: string; + scanId: string; + version: string; + targetPlatform: string; + scanStatus: string; + ruleName: string; + reason: string; + dateDetected: string; + enforcedFlag: boolean; +} + +export interface NameSquattingFlag { + namespace: string; + extensionName: string; + displayName: string; + publisher: string; + publisherUrl?: string; + state: NameSquattingState; + activeVersionCount: number; + findingCount: number; + dateFirstDetected: string; + dateLastDetected: string; + findings: NameSquattingFinding[]; +} + +export interface NameSquattingFlagList { + success?: string; + warning?: string; + error?: string; + offset: number; + totalSize: number; + flags: NameSquattingFlag[]; +} + +export interface NameSquattingCounts { + total: number; + published: number; + deactivated: number; + rejected: number; +} + +export interface NameSquattingTarget { + namespace: string; + extension: string; +} + +export interface NameSquattingActionRequest { + targets: NameSquattingTarget[]; +} + +export interface NameSquattingActionResult { + namespace: string; + extension: string; + success: boolean; + message?: string; + error?: string; +} + +export interface NameSquattingActionResponse { + processed: number; + successful: number; + failed: number; + results: NameSquattingActionResult[]; +} + export enum TierType { FREE = 'FREE', SAFETY = 'SAFETY', diff --git a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts index f8837d9d3..2b0a15cff 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts +++ b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts @@ -17,6 +17,7 @@ export namespace AdminDashboardRoutes { export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); export const SCANS_ADMIN = createRoute([ROOT, 'scans']); + export const NAME_SQUATTING = createRoute([ROOT, 'name-squatting']); export const TIERS = createRoute([ROOT, 'tiers']); export const CUSTOMERS = createRoute([ROOT, 'customers']); export const USAGE_STATS = createRoute([ROOT, 'usage']); diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index cb78f7e45..fe57843b1 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -16,6 +16,7 @@ import AccountBoxIcon from '@mui/icons-material/AccountBox'; import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; import BarChartIcon from '@mui/icons-material/BarChart'; import ExtensionSharpIcon from '@mui/icons-material/ExtensionSharp'; +import GavelIcon from '@mui/icons-material/Gavel'; import HistoryIcon from '@mui/icons-material/History'; import PeopleIcon from '@mui/icons-material/People'; import PersonIcon from '@mui/icons-material/Person'; @@ -33,6 +34,7 @@ import { isNavGroup, NavEntry } from './nav-types'; import { NamespaceAdmin } from './namespace-admin'; import { PublisherAdmin } from './publisher-admin'; import { ScanAdmin } from './scan-admin'; +import { NameSquatting } from './name-squatting/name-squatting'; import { Tiers } from './tiers/tiers'; import { Customers } from './customers/customers'; import { CustomerDetails } from './customers/customer-details'; @@ -68,6 +70,12 @@ const navConfig: NavEntry[] = [ icon: , description: 'View security scan results and manage quarantined extensions' }, + { + path: AdminDashboardRoutes.NAME_SQUATTING, + name: 'Name Squatting', + icon: , + description: 'Moderate extensions flagged by the name squatting publisher check' + }, { name: 'Rate Limiting', icon: , @@ -172,6 +180,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/name-squatting/name-squatting-action-dialog.tsx b/webui/src/pages/admin-dashboard/name-squatting/name-squatting-action-dialog.tsx new file mode 100644 index 000000000..61f039aad --- /dev/null +++ b/webui/src/pages/admin-dashboard/name-squatting/name-squatting-action-dialog.tsx @@ -0,0 +1,111 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC, useState } from 'react'; +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography +} from '@mui/material'; +import type { NameSquattingFlag } from '../../../extension-registry-types'; +import { handleError } from '../../../utils'; + +export type NameSquattingAction = 'clear' | 'delete'; + +export interface NameSquattingActionDialogProps { + open: boolean; + action: NameSquattingAction; + flag?: NameSquattingFlag; + onClose: () => void; + onConfirm: () => Promise; +} + +export const NameSquattingActionDialog: FC = ({ + open, + action, + flag, + onClose, + onConfirm +}) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleConfirm = async () => { + try { + setError(null); + setLoading(true); + await onConfirm(); + onClose(); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }; + + const extensionId = flag ? `${flag.namespace}.${flag.extensionName}` : ''; + const clearing = action === 'clear'; + + return ( +

+ {clearing ? 'Mark as false positive' : 'Soft delete extension'} + + {error && {error}} + + {clearing ? ( + <> + + Clear the {flag?.findingCount ?? 0} name squatting{' '} + {flag?.findingCount === 1 ? 'finding' : 'findings'} recorded for{' '} + {extensionId}? + + + The findings are deleted, so the extension no longer appears here. The record of the check + having run is kept with the scan, and this action is written to the admin log. + + + ) : ( + <> + + Deactivate all {flag?.activeVersionCount ?? 0} active{' '} + {flag?.activeVersionCount === 1 ? 'version' : 'versions'} of {extensionId}? + + + The extension becomes unavailable for download and search. Its records are kept and its + version identities stay reserved, and this action is written to the admin log. + + + )} + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/name-squatting/name-squatting-row.tsx b/webui/src/pages/admin-dashboard/name-squatting/name-squatting-row.tsx new file mode 100644 index 000000000..f0e46a63a --- /dev/null +++ b/webui/src/pages/admin-dashboard/name-squatting/name-squatting-row.tsx @@ -0,0 +1,185 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC, useState } from 'react'; +import { + Box, + Button, + Chip, + Collapse, + Divider, + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import type { NameSquattingFlag, NameSquattingState } from '../../../extension-registry-types'; + +const RowPaper = styled(Box)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1) +})); + +const ExpandButton = styled(IconButton, { shouldForwardProp: prop => prop !== 'expanded' })<{ expanded: boolean }>( + ({ theme, expanded }) => ({ + transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', + transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shortest }) + }) +); + +const HeaderRow = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + flexWrap: 'wrap' +})); + +const InlineGroup = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + flexWrap: 'wrap' +})); + +const stateLabels: Record = { + PUBLISHED: { label: 'Published', color: 'success' }, + DEACTIVATED: { label: 'Deactivated', color: 'default' }, + REJECTED: { label: 'Publication blocked', color: 'warning' } +}; + +const formatDate = (value: string) => new Date(value).toLocaleString(); + +export interface NameSquattingRowProps { + flag: NameSquattingFlag; + onClear: (flag: NameSquattingFlag) => void; + onDelete: (flag: NameSquattingFlag) => void; +} + +export const NameSquattingRow: FC = ({ flag, onClear, onDelete }) => { + const [expanded, setExpanded] = useState(false); + + const extensionId = `${flag.namespace}.${flag.extensionName}`; + const state = stateLabels[flag.state]; + // Publication was blocked, so there is no extension to clear findings on or to deactivate. + const rejected = flag.state === 'REJECTED'; + + return ( + + + + {flag.displayName} + + {extensionId} · published by {flag.publisher} + + + + + + + Last flagged {formatDate(flag.dateLastDetected)} + + setExpanded(prev => !prev)} + aria-label={expanded ? `Hide findings for ${extensionId}` : `Show findings for ${extensionId}`} + aria-expanded={expanded} + size='small'> + + + + + + + {rejected ? ( + + Publication was blocked by the check, so there is no extension to moderate. + + ) : ( + <> + + + + + + + + {flag.activeVersionCount} active {flag.activeVersionCount === 1 ? 'version' : 'versions'} + + + )} + + + + + + + + Version + Scan + Rule + Reason + Detected + + + + {flag.findings.map(finding => ( + + + {finding.version} + {finding.targetPlatform && finding.targetPlatform !== 'universal' + ? ` (${finding.targetPlatform})` + : ''} + + + {finding.scanStatus} + {finding.enforcedFlag ? ' (enforced)' : ''} + + {finding.ruleName} + {finding.reason} + {formatDate(finding.dateDetected)} + + ))} + +
+
+
+ ); +}; diff --git a/webui/src/pages/admin-dashboard/name-squatting/name-squatting.tsx b/webui/src/pages/admin-dashboard/name-squatting/name-squatting.tsx new file mode 100644 index 000000000..d8a1f2e24 --- /dev/null +++ b/webui/src/pages/admin-dashboard/name-squatting/name-squatting.tsx @@ -0,0 +1,256 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC, useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + CircularProgress, + Paper, + TablePagination, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import type { NameSquattingFlag, NameSquattingState } from '../../../extension-registry-types'; +import { handleError } from '../../../utils'; +import { useDebouncedCallback } from '../../../hooks/use-debounced-callback'; +import { NameSquattingRow } from './name-squatting-row'; +import { NameSquattingAction, NameSquattingActionDialog } from './name-squatting-action-dialog'; +import { + NameSquattingFilters, + useClearNameSquattingFlags, + useDeleteNameSquattingExtensions, + useNameSquattingCounts, + useNameSquattingFlags +} from './use-name-squatting'; + +const PageLayout = styled(Box)(({ theme }) => ({ + padding: theme.spacing(3), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2) +})); + +const FilterBar = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(2), + flexWrap: 'wrap', + alignItems: 'center' +})); + +const FlagList = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2) +})); + +const CenteredProgress = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', + padding: theme.spacing(8, 0) +})); + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +const STATE_OPTIONS: { value: NameSquattingState; label: string }[] = [ + { value: 'PUBLISHED', label: 'Published' }, + { value: 'DEACTIVATED', label: 'Deactivated' }, + { value: 'REJECTED', label: 'Publication blocked' } +]; + +export const NameSquatting: FC = () => { + const [publisherInput, setPublisherInput] = useState(''); + const [namespaceInput, setNamespaceInput] = useState(''); + const [nameInput, setNameInput] = useState(''); + const [search, setSearch] = useState({}); + const [states, setStates] = useState([]); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0]); + const [action, setAction] = useState('clear'); + const [selectedFlag, setSelectedFlag] = useState(); + const [dialogOpen, setDialogOpen] = useState(false); + const [errorDismissed, setErrorDismissed] = useState(false); + + const applySearch = useDebouncedCallback((next: NameSquattingFilters) => { + setSearch(next); + setPage(0); + }); + + useEffect(() => { + applySearch({ + publisher: publisherInput.trim() || undefined, + namespace: namespaceInput.trim() || undefined, + name: nameInput.trim() || undefined + }); + }, [applySearch, publisherInput, namespaceInput, nameInput]); + + const filters = useMemo( + () => ({ ...search, state: states.length > 0 ? states : undefined }), + [search, states] + ); + + const { data, isFetching: loading, error: loadError } = useNameSquattingFlags(filters, page * pageSize, pageSize); + const { data: counts } = useNameSquattingCounts(filters); + const { mutateAsync: clearFlags } = useClearNameSquattingFlags(); + const { mutateAsync: deleteExtensions } = useDeleteNameSquattingExtensions(); + + const flags: readonly NameSquattingFlag[] = data?.flags ?? []; + const totalSize = data?.totalSize ?? 0; + + // A fresh load error should be shown again even if a previous one was dismissed. + useEffect(() => { + setErrorDismissed(false); + }, [loadError]); + + const error = loadError && !errorDismissed ? handleError(loadError as Error) : null; + + const openDialog = (nextAction: NameSquattingAction, flag: NameSquattingFlag) => { + setAction(nextAction); + setSelectedFlag(flag); + setDialogOpen(true); + }; + + const handleConfirm = async () => { + if (!selectedFlag) { + return; + } + const targets = [{ namespace: selectedFlag.namespace, extension: selectedFlag.extensionName }]; + if (action === 'clear') { + await clearFlags(targets); + } else { + await deleteExtensions(targets); + } + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setSelectedFlag(undefined); + }; + + const countLabel = (state: NameSquattingState) => { + if (!counts) { + return ''; + } + const value = { PUBLISHED: counts.published, DEACTIVATED: counts.deactivated, REJECTED: counts.rejected }[ + state + ]; + return ` (${value})`; + }; + + return ( + + + + Name Squatting + + + Extensions flagged by the name squatting publisher check. Clear the check on an extension whose + match is a false positive, or soft delete one that turns out to be squatting a name. + {counts ? ` ${counts.total} flagged in total.` : ''} + + + + + setNamespaceInput(event.target.value)} + /> + setNameInput(event.target.value)} + /> + setPublisherInput(event.target.value)} + /> + { + setStates(next); + setPage(0); + }} + aria-label='Filter by extension state'> + {STATE_OPTIONS.map(option => ( + + {option.label} + {countLabel(option.value)} + + ))} + + + + {error && ( + setErrorDismissed(true)}> + {error} + + )} + + {loading && flags.length === 0 && ( + + + + )} + + {!loading && !error && flags.length === 0 && ( + + No extensions are flagged for name squatting. + + )} + + {flags.length > 0 && ( + <> + + {flags.map(flag => ( + openDialog('clear', selected)} + onDelete={selected => openDialog('delete', selected)} + /> + ))} + + setPage(nextPage)} + rowsPerPage={pageSize} + rowsPerPageOptions={PAGE_SIZE_OPTIONS} + onRowsPerPageChange={event => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + /> + + )} + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/name-squatting/use-name-squatting.ts b/webui/src/pages/admin-dashboard/name-squatting/use-name-squatting.ts new file mode 100644 index 000000000..5d0ef9ce6 --- /dev/null +++ b/webui/src/pages/admin-dashboard/name-squatting/use-name-squatting.ts @@ -0,0 +1,101 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { useContext } from 'react'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { MainContext } from '../../../context'; +import type { + NameSquattingActionResponse, + NameSquattingState, + NameSquattingTarget +} from '../../../extension-registry-types'; +import { controllerFromSignal } from '../../../query-client'; + +export interface NameSquattingFilters { + publisher?: string; + namespace?: string; + name?: string; + state?: NameSquattingState[]; +} + +export const nameSquattingKeys = { + all: ['admin', 'name-squatting'] as const, + list: (filters: NameSquattingFilters, offset: number, size: number) => + ['admin', 'name-squatting', 'list', filters, offset, size] as const, + counts: (filters: NameSquattingFilters) => ['admin', 'name-squatting', 'counts', filters] as const +}; + +/** + * Loads a page of extensions flagged by the name squatting check. `keepPreviousData` keeps the + * current rows on screen while a new page or a changed filter is fetched. + */ +export const useNameSquattingFlags = (filters: NameSquattingFilters, offset: number, size: number) => { + const { service } = useContext(MainContext); + return useQuery({ + queryKey: nameSquattingKeys.list(filters, offset, size), + queryFn: ({ signal }) => + service.admin.getNameSquattingFlags(controllerFromSignal(signal), { ...filters, offset, size }), + placeholderData: keepPreviousData + }); +}; + +/** + * Loads the number of flagged extensions per state. The state filter is deliberately not passed on, + * so the counts stay stable while the administrator switches between states. + */ +export const useNameSquattingCounts = (filters: NameSquattingFilters) => { + const { service } = useContext(MainContext); + const { publisher, namespace, name } = filters; + return useQuery({ + queryKey: nameSquattingKeys.counts({ publisher, namespace, name }), + queryFn: ({ signal }) => + service.admin.getNameSquattingCounts(controllerFromSignal(signal), { publisher, namespace, name }) + }); +}; + +/** + * Marks the findings for one or more extensions as a false positive, clearing the check error. + */ +export const useClearNameSquattingFlags = () => { + const { service } = useContext(MainContext); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (targets: NameSquattingTarget[]) => + failOnRejectedTargets(await service.admin.clearNameSquattingFlags({ targets })), + onSuccess: () => queryClient.invalidateQueries({ queryKey: nameSquattingKeys.all }) + }); +}; + +/** + * Soft-deletes one or more flagged extensions, making them unavailable while keeping their records. + */ +export const useDeleteNameSquattingExtensions = () => { + const { service } = useContext(MainContext); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (targets: NameSquattingTarget[]) => + failOnRejectedTargets(await service.admin.deleteNameSquattingExtensions({ targets })), + onSuccess: () => queryClient.invalidateQueries({ queryKey: nameSquattingKeys.all }) + }); +}; + +/** + * The moderation endpoints answer 200 with per-extension outcomes, so a request that changed + * nothing has to be turned into a rejection for the caller's error path to see it. + */ +const failOnRejectedTargets = (response: Readonly): NameSquattingActionResponse => { + if (response.successful === 0 && response.failed > 0) { + throw new Error(response.results.find(result => result.error)?.error ?? 'The action could not be applied'); + } + return response; +}; diff --git a/webui/test/setup.ts b/webui/test/setup.ts index bc4b0d2a4..a0fada22a 100644 --- a/webui/test/setup.ts +++ b/webui/test/setup.ts @@ -20,3 +20,17 @@ import { cleanup } from '@testing-library/react'; afterEach(() => { cleanup(); }); + +// jsdom 30 throws while computing a `calc()` that mixes a percentage with a length, which is what +// MUI's dialog paper uses for its width and max-height. Testing Library computes styles for every +// candidate when it queries by role, so one such element would break every role query made while a +// dialog is open. Fall back to an empty declaration for those elements: the accessibility checks +// read `display` and `visibility`, and neither is what jsdom failed on. +const computeStyle = window.getComputedStyle.bind(window); +window.getComputedStyle = ((element: Element, pseudoElement?: string | null) => { + try { + return computeStyle(element, pseudoElement); + } catch { + return document.createElement('div').style; + } +}) as typeof window.getComputedStyle; diff --git a/webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting-row.spec.tsx b/webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting-row.spec.tsx new file mode 100644 index 000000000..ce55f6948 --- /dev/null +++ b/webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting-row.spec.tsx @@ -0,0 +1,79 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../support/test-providers'; +import { NameSquattingRow } from '../../../../../src/pages/admin-dashboard/name-squatting/name-squatting-row'; +import { flag } from '../../../support/name-squatting-data'; + +const clearButton = () => screen.queryByRole('button', { name: /mark as false positive/i }); +const deleteButton = () => screen.queryByRole('button', { name: /soft delete extension/i }); + +describe('NameSquattingRow', () => { + it('offers both moderation actions for a published extension', async () => { + const onClear = vi.fn(); + const onDelete = vi.fn(); + renderWithProviders(); + + expect(screen.getByText('Squatty Theme')).toBeInTheDocument(); + expect(screen.getByText(/squatter\.squatty-theme/)).toBeInTheDocument(); + + await userEvent.click(clearButton()!); + await userEvent.click(deleteButton()!); + + expect(onClear).toHaveBeenCalledWith(flag()); + expect(onDelete).toHaveBeenCalledWith(flag()); + }); + + // A rejected extension never made it into the registry, so neither action has anything to act on. + it('replaces the actions with an explanation when publication was blocked', () => { + renderWithProviders( + + ); + + expect(screen.getByText(/publication was blocked by the check/i)).toBeInTheDocument(); + expect(clearButton()).not.toBeInTheDocument(); + expect(deleteButton()).not.toBeInTheDocument(); + }); + + it('keeps the clear action but disables soft delete once no active versions are left', () => { + renderWithProviders( + + ); + + expect(clearButton()).toBeEnabled(); + expect(deleteButton()).toBeDisabled(); + }); + + it('lists the individual findings once the row is expanded', async () => { + renderWithProviders(); + + expect(screen.queryByText('Levenshtein Distance')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /show findings for squatter\.squatty-theme/i })); + + expect(screen.getByText('Levenshtein Distance')).toBeInTheDocument(); + expect(screen.getByText(/too similar to squatter-target\.theme/i)).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + }); +}); diff --git a/webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting.spec.tsx b/webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting.spec.tsx new file mode 100644 index 000000000..128e54a5a --- /dev/null +++ b/webui/test/unit/pages/admin-dashboard/name-squatting/name-squatting.spec.tsx @@ -0,0 +1,168 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../support/test-providers'; +import { NameSquatting } from '../../../../../src/pages/admin-dashboard/name-squatting/name-squatting'; +import { AdminService, ExtensionRegistryService } from '../../../../../src/extension-registry-service'; +import type { NameSquattingActionResponse, NameSquattingFlag } from '../../../../../src/extension-registry-types'; +import { counts, flag } from '../../../support/name-squatting-data'; + +interface AdminStubs { + flags?: NameSquattingFlag[]; + clearResponse?: NameSquattingActionResponse; +} + +function stubService({ flags = [flag()], clearResponse }: AdminStubs = {}) { + const admin = { + getNameSquattingFlags: vi.fn().mockResolvedValue({ offset: 0, totalSize: flags.length, flags }), + getNameSquattingCounts: vi.fn().mockResolvedValue(counts()), + clearNameSquattingFlags: vi.fn().mockResolvedValue( + clearResponse ?? { + processed: 1, + successful: 1, + failed: 0, + results: [{ namespace: 'squatter', extension: 'squatty-theme', success: true }] + } + ), + deleteNameSquattingExtensions: vi.fn().mockResolvedValue({ + processed: 1, + successful: 1, + failed: 0, + results: [{ namespace: 'squatter', extension: 'squatty-theme', success: true }] + }) + } as unknown as AdminService; + + return { service: { serverUrl: 'https://open-vsx.org', admin } as ExtensionRegistryService, admin }; +} + +describe('NameSquatting', () => { + it('lists the flagged extensions returned for the first page', async () => { + const { service, admin } = stubService(); + renderWithProviders(, { mainContext: { service } }); + + expect(await screen.findByText('Squatty Theme')).toBeInTheDocument(); + expect(admin.getNameSquattingFlags).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ offset: 0, size: 10 }) + ); + }); + + it('shows the per-state counts on the state filters', async () => { + const { service } = stubService(); + renderWithProviders(, { mainContext: { service } }); + + expect(await screen.findByRole('button', { name: /published \(1\)/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /publication blocked \(0\)/i })).toBeInTheDocument(); + }); + + it('narrows the query to the selected state', async () => { + const { service, admin } = stubService(); + renderWithProviders(, { mainContext: { service } }); + await screen.findByText('Squatty Theme'); + + await userEvent.click(screen.getByRole('button', { name: /publication blocked/i })); + + await waitFor(() => + expect(admin.getNameSquattingFlags).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ state: ['REJECTED'] }) + ) + ); + }); + + it('passes the typed search terms to the query once they settle', async () => { + const { service, admin } = stubService(); + renderWithProviders(, { mainContext: { service } }); + await screen.findByText('Squatty Theme'); + + await userEvent.type(screen.getByLabelText('Namespace'), 'squat'); + + await waitFor(() => + expect(admin.getNameSquattingFlags).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ namespace: 'squat' }) + ) + ); + }); + + it('clears the findings for the confirmed extension', async () => { + const { service, admin } = stubService(); + renderWithProviders(, { mainContext: { service } }); + await screen.findByText('Squatty Theme'); + + await userEvent.click(screen.getByRole('button', { name: /mark as false positive/i })); + await userEvent.click(screen.getByRole('button', { name: /clear findings/i })); + + await waitFor(() => + expect(admin.clearNameSquattingFlags).toHaveBeenCalledWith({ + targets: [{ namespace: 'squatter', extension: 'squatty-theme' }] + }) + ); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('soft-deletes the confirmed extension', async () => { + const { service, admin } = stubService(); + renderWithProviders(, { mainContext: { service } }); + await screen.findByText('Squatty Theme'); + + await userEvent.click(screen.getByRole('button', { name: /soft delete extension/i })); + await userEvent.click(screen.getByRole('button', { name: /^soft delete$/i })); + + await waitFor(() => + expect(admin.deleteNameSquattingExtensions).toHaveBeenCalledWith({ + targets: [{ namespace: 'squatter', extension: 'squatty-theme' }] + }) + ); + }); + + // The endpoint answers 200 with per-extension outcomes, so a rejected target has to reach the + // dialog rather than being reported as a success. + it('keeps the dialog open and reports why a moderation action was refused', async () => { + const { service } = stubService({ + clearResponse: { + processed: 1, + successful: 0, + failed: 1, + results: [ + { + namespace: 'squatter', + extension: 'squatty-theme', + success: false, + error: 'No name squatting findings are recorded for this extension' + } + ] + } + }); + renderWithProviders(, { mainContext: { service } }); + await screen.findByText('Squatty Theme'); + + await userEvent.click(screen.getByRole('button', { name: /mark as false positive/i })); + await userEvent.click(screen.getByRole('button', { name: /clear findings/i })); + + expect( + await screen.findByText(/no name squatting findings are recorded for this extension/i) + ).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('tells the administrator when nothing is flagged', async () => { + const { service } = stubService({ flags: [] }); + renderWithProviders(, { mainContext: { service } }); + + expect(await screen.findByText(/no extensions are flagged for name squatting/i)).toBeInTheDocument(); + }); +}); diff --git a/webui/test/unit/support/name-squatting-data.ts b/webui/test/unit/support/name-squatting-data.ts new file mode 100644 index 000000000..f400ea401 --- /dev/null +++ b/webui/test/unit/support/name-squatting-data.ts @@ -0,0 +1,53 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import type { + NameSquattingCounts, + NameSquattingFinding, + NameSquattingFlag +} from '../../../src/extension-registry-types'; + +export function finding(overrides: Partial = {}): NameSquattingFinding { + return { + id: '1', + scanId: '10', + version: '1.0.0', + targetPlatform: 'universal', + scanStatus: 'PASSED', + ruleName: 'Levenshtein Distance', + reason: 'Too similar to squatter-target.theme', + dateDetected: '2026-02-11T14:00Z', + enforcedFlag: false, + ...overrides + }; +} + +export function flag(overrides: Partial = {}): NameSquattingFlag { + return { + namespace: 'squatter', + extensionName: 'squatty-theme', + displayName: 'Squatty Theme', + publisher: 'squatter-user', + state: 'PUBLISHED', + activeVersionCount: 2, + findingCount: 1, + dateFirstDetected: '2026-02-11T14:00Z', + dateLastDetected: '2026-02-11T14:00Z', + findings: [finding()], + ...overrides + }; +} + +export function counts(overrides: Partial = {}): NameSquattingCounts { + return { total: 1, published: 1, deactivated: 0, rejected: 0, ...overrides }; +} From 3337bed53c0d5a1cca3c3e77e197a93ef1a67628 Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Thu, 20 Aug 2026 14:17:07 -0400 Subject: [PATCH 2/3] feat: Add support for strict publish validation of case insensitive display name matches To best accomadate the current infrastructure, a new check was added to the publish workflow. This check will compare the display name of new extensions and new versions to existing data. For new extensions, exact matches will be blocked from upload. In the case of versions, extensions whose name changes to conflict will be blocked, but existing names will not be blocked. This carve out was added to address any existing conflict of display name without potentially blocking a legitimate version from being submitted. This grandfathered clause makes the assumption that the name squatting results will be monitored and addressed to get to a known good state. Once we get to a clean state, the grandfathered clause will effectively work as a standard exact match gate without any notable performance impact. Assisted-by: Claude Opus 5 --- .../PublishExtensionVersionHandler.java | 105 +++++- .../repositories/ExtensionJooqRepository.java | 73 ++++ .../repositories/RepositoryService.java | 4 + ...72__ExtensionVersion_DisplayName_Index.sql | 39 ++ .../org/eclipse/openvsx/RegistryAPITest.java | 3 +- .../PublishExtensionVersionHandlerTest.java | 351 +++++++++++++++++- .../ExtensionJooqRepositoryTest.java | 106 +++++- .../RepositoryServiceSmokeTest.java | 1 + 8 files changed, 671 insertions(+), 11 deletions(-) create mode 100644 server/src/main/resources/db/migration/V1_72__ExtensionVersion_DisplayName_Index.sql diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java index e32354e01..264bb6133 100644 --- a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java @@ -14,6 +14,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.TreeSet; import java.util.function.Consumer; import java.util.function.Predicate; @@ -29,6 +30,7 @@ import org.jobrunr.scheduling.JobRequestScheduler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.resilience.annotation.Retryable; @@ -71,6 +73,7 @@ public class PublishExtensionVersionHandler { private final ExtensionValidator validator; private final ExtensionControlService extensionControl; private final ExtensionScanService scanService; + private final boolean mirrorEnabled; private final Predicate unsupportedIconExtensions; @@ -84,7 +87,8 @@ public PublishExtensionVersionHandler( UserService users, ExtensionValidator validator, ExtensionControlService extensionControl, - ExtensionScanService scanService + ExtensionScanService scanService, + @Value("${ovsx.data.mirror.enabled:false}") boolean mirrorEnabled ) { this.config = config; this.service = service; @@ -96,6 +100,7 @@ public PublishExtensionVersionHandler( this.validator = validator; this.extensionControl = extensionControl; this.scanService = scanService; + this.mirrorEnabled = mirrorEnabled; this.unsupportedIconExtensions = path -> { if (path == null) { @@ -182,6 +187,11 @@ public void checkPublishPreconditions(ExtensionProcessor processor, PersonalAcce throw new ErrorResultException( alreadyPublishedMessage(namespace.getName(), extensionName, existingVersion)); } + var latestVersion = repositories + .findLatestVersion(namespace.getName(), extensionName, null, false, true); + if (adoptsDisplayName(latestVersion, processor.getDisplayName())) { + checkDisplayNameConflict(namespace.getName(), processor.getDisplayName(), token.getUser()); + } } private Namespace checkPublishPermission(ExtensionProcessor processor, UserData user) { @@ -265,6 +275,20 @@ private ExtensionVersion createExtensionVersion( } if (extension == null) { + // Nothing carries the name of this extension yet, so this publication is what introduces it + // to the registry and is the one that has to hold up its display name against the rest of + // it. Repeated here rather than left to checkPublishPreconditions because that one only + // runs on the paths that scan, and this is the only call site every publication reaches. + // + // The namespace lock held at this point does not make the check atomic, and is not meant + // to: a conflict is by definition in another namespace, so two publications racing for one + // display name lock different rows and do not serialise against each other. The check is a + // speed bump against the cheapest impersonation rather than an enforced invariant -- there + // is no constraint that could enforce it, the rule being over the latest active version and + // scoped to the publisher's own namespaces -- so the remaining race is acceptable, and + // leaves behind a duplicate an admin can resolve. + checkDisplayNameConflict(namespaceName, displayName, user); + extension = new Extension(); extension.setActive(false); extension.setName(extensionName); @@ -281,6 +305,17 @@ private ExtensionVersion createExtensionVersion( throw new ErrorResultException( alreadyPublishedMessage(namespaceName, extensionName, existingVersion)); } + + // A version that renames the extension is the publication that adopts the new name, and so + // is the one that has to hold it up against the rest of the registry. Without this, the + // check on a new extension would only be a speed bump one publication wide: an extension + // could be introduced under a name of its own, pass, and then take the display name of a + // popular extension in its next version -- the manifest being the source of truth for the + // name the registry shows. + var latestVersion = repositories.findLatestVersion(extension, null, false, true); + if (adoptsDisplayName(latestVersion, displayName)) { + checkDisplayNameConflict(namespaceName, displayName, user); + } } extension.setLastUpdatedDate(extVersion.getTimestamp()); @@ -315,6 +350,74 @@ private void validateExtensionName(String namespaceName, String extensionName, S } } + /** + * Whether this publication is the one that adopts the display name it carries, and so has to hold + * it up against the rest of the registry. + *

+ * It is, when the name is not the one the extension already shows: either there is no visible + * version to compare against, which is the case for an extension being introduced, or this version + * renames an existing one. A version carrying the name its extension already shows is not adopting + * anything, and is skipped -- which keeps the check off the routine version bumps of extensions + * that already share a display name with another. The registry holds many such pairs, predating + * this check, and they would otherwise be left unable to publish at all. + *

+ * Compared with casing and surrounding whitespace normalised away, matching how + * {@link org.eclipse.openvsx.repositories.RepositoryService#findActiveExtensionByDisplayName} + * compares the names it searches: a name differing only there reads the same, and is one the + * extension already holds rather than one it is taking. + * + * @param latestVersion the version the registry currently shows for the extension, or {@code null} + * when it shows none + */ + private boolean adoptsDisplayName(ExtensionVersion latestVersion, String displayName) { + var currentDisplayName = latestVersion != null ? latestVersion.getDisplayName() : null; + return !normalizeDisplayName(currentDisplayName).equals(normalizeDisplayName(displayName)); + } + + private String normalizeDisplayName(String displayName) { + return StringUtils.trimToEmpty(displayName).toLowerCase(Locale.ROOT); + } + + /** + * Rejects an extension that would be published under the display name of an existing one. + *

+ * The display name is what a user reads when picking an extension out of a list, and unlike the + * extension id it is not unique by construction, so a package can be published under the exact + * name of a popular extension and be taken for it. Only exact matches are rejected here, which + * costs an honest publisher nothing (any distinguishing suffix passes) while removing the cheapest + * way of impersonating another extension. The near misses that remain -- a transposed character, a + * homoglyph -- are the business of the NAME_SQUATTING check, which reports them for review because + * it is far too imprecise to reject a publication on its own. + *

+ * Extensions in namespaces the publisher belongs to are not conflicts: a publisher shipping two + * extensions under one name is not impersonating anyone, and the name is already theirs. + *

+ * Skipped entirely when this instance mirrors another registry. A mirror has to end up with what + * its upstream has, duplicate display names included; rejecting them would not protect anyone from + * an extension that is already published upstream, and would leave the mirror permanently + * incomplete, as every run would fail on the same extension again. + */ + private void checkDisplayNameConflict(String namespaceName, String displayName, UserData user) { + if (mirrorEnabled || StringUtils.isBlank(displayName)) { + return; + } + + var excludedNamespaces = new ArrayList(); + // The target namespace need not be one the publisher is a member of: a privileged user may + // publish into any of them, and would otherwise collide with the namespace's own extensions. + excludedNamespaces.add(namespaceName); + repositories.findMemberships(user) + .forEach(membership -> excludedNamespaces.add(membership.getNamespace().getName())); + + var conflict = repositories.findActiveExtensionByDisplayName(displayName, excludedNamespaces); + if (conflict != null) { + var conflictId = NamingUtil.toExtensionId(conflict.getNamespace().getName(), conflict.getName()); + throw new ErrorResultException( + "Display name '" + displayName + "' is already used by the extension '" + conflictId + + "'. Please choose a different display name."); + } + } + /** * This method checks whether the metadata as contained in {@code extension.vsixmanifest} matches * the data in {@code package.json}. diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java index 9114d9e8b..2de700f9b 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java @@ -12,7 +12,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import org.jooq.*; import org.jooq.Record; @@ -448,4 +450,75 @@ public List findSimilarExtensionsByLevenshtein( return query.fetch().map(this::toExtension); } + + /** + * Finds an active extension whose latest version carries the given display name, skipping the + * namespaces in {@code excludeNamespaces}. + *

+ * The display names are compared as a reader seeing the two extensions side by side would compare + * them, rather than byte for byte: casing and surrounding whitespace are normalised away, as + * neither is visible enough to tell two extensions apart. Everything beyond that -- transposed + * characters, homoglyphs, added punctuation -- is left to {@link + * #findSimilarExtensionsByLevenshtein}, which reports rather than rejects and is far too + * imprecise to decide a publication on. + *

+ * Only the latest version of an extension is considered, because that is the name the registry + * shows for it. An extension that carried the name in an earlier version and has since renamed + * itself does not hold the name any longer, and does not stand in the way of another extension + * taking it. + * + * @return the conflicting extension, or {@code null} if no other extension shows that name + */ + public Extension findActiveExtensionByDisplayName(String displayName, Collection excludeNamespaces) { + if (displayName == null || displayName.isBlank()) { + return null; + } + + // Both sides are normalised by the database rather than one of them in Java, so that the + // comparison cannot be thrown off by the two disagreeing on what lower casing means. + var normalizedDisplayName = DSL.lower(DSL.trim(DSL.val(displayName))); + + // The row that matches the display name. Aliased because the correlated subquery below selects + // from the unaliased EXTENSION_VERSION and would otherwise be shadowed by this one. + var evMatch = EXTENSION_VERSION.as("ev_match"); + + // Correlated to the display name match rather than to EXTENSION, so that the subquery depends + // only on the rows the display name index already narrowed down to. Correlating it to + // EXTENSION.ID instead lets the planner hash join the two and evaluate the subquery once per + // row of a sequential scan over EXTENSION, which costs a full scan of that table plus one index + // probe per extension on every publication, and grows with the table rather than with the + // number of extensions actually carrying the name. + var latestQuery = extensionVersionRepo.findLatestQuery(null, false, true); + latestQuery.addSelect(EXTENSION_VERSION.ID); + latestQuery.addConditions(EXTENSION_VERSION.EXTENSION_ID.eq(evMatch.EXTENSION_ID)); + var latestVersionId = latestQuery.asField().coerce(Long.class); + + var query = findAllActive(); + query.addJoin(evMatch, evMatch.EXTENSION_ID.eq(EXTENSION.ID)); + query.addConditions( + evMatch.ACTIVE.eq(true), + DSL.lower(DSL.trim(evMatch.DISPLAY_NAME)).eq(normalizedDisplayName), + evMatch.ID.eq(latestVersionId)); + + if (excludeNamespaces != null && !excludeNamespaces.isEmpty()) { + // Upper cased to match the unique index on UPPER(name), and nulls dropped because a single + // null in a NOT IN list makes the whole predicate null, which would silently stop the check + // from ever reporting a conflict. + var excluded = excludeNamespaces.stream() + .filter(Objects::nonNull) + .map(name -> name.toUpperCase(Locale.ROOT)) + .toList(); + if (!excluded.isEmpty()) { + query.addConditions(DSL.upper(NAMESPACE.NAME).notIn(excluded)); + } + } + + // Deliberately unordered. Any ORDER BY here lets the planner satisfy the ordering by walking + // that index and stopping at the first match, rather than driving off the display name index -- + // which it costs as cheap because it has no idea how rare a match is, and which walks the whole + // EXTENSION table when there is none. Which of several conflicting extensions gets named in the + // rejection message is arbitrary, and not worth that. + query.addLimit(1); + return query.fetchOne(this::toExtension); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index ae5d00505..04191265b 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -1027,6 +1027,10 @@ public boolean isDeleteAllActiveVersions( return extensionVersionJooqRepo.isDeleteAllActiveVersions(namespaceName, extensionName, targetVersions); } + public Extension findActiveExtensionByDisplayName(String displayName, Collection excludeNamespaces) { + return extensionJooqRepo.findActiveExtensionByDisplayName(displayName, excludeNamespaces); + } + public List findSimilarExtensionsByLevenshtein( String extensionName, String namespaceName, diff --git a/server/src/main/resources/db/migration/V1_72__ExtensionVersion_DisplayName_Index.sql b/server/src/main/resources/db/migration/V1_72__ExtensionVersion_DisplayName_Index.sql new file mode 100644 index 000000000..f23fce784 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_72__ExtensionVersion_DisplayName_Index.sql @@ -0,0 +1,39 @@ +-- Backs the display name conflict check that publishing a new extension runs (see +-- ExtensionJooqRepository#findActiveExtensionByDisplayName): a package whose display name is already +-- carried by another publisher's extension is rejected, so every initial publication looks the +-- incoming display name up once. +-- +-- The expression has to match the one used by the query, or the planner falls back to a sequential +-- scan over every version there is: the lookup normalises casing and surrounding whitespace away, +-- because neither is visible enough to tell two extensions apart when they are read side by side. +-- jOOQ renders that normalisation as lower(TRIM(BOTH FROM display_name)), which PostgreSQL +-- canonicalises to lower(btrim(display_name)) -- the expression indexed here. +-- +-- Restricted to the versions that are publicly visible, which is all the check looks at, and which +-- keeps the index off the inactive versions that make up the bulk of the table. +-- +-- Deliberately not CONCURRENTLY. Flyway holds a transaction open on a second connection for the +-- duration of a migration run, and CREATE INDEX CONCURRENTLY only completes once every transaction +-- that was open alongside it has finished -- so under Flyway it waits on Flyway itself and never +-- returns. Setting flyway:executeInTransaction=false lets the statement start but does not help it +-- finish. +-- +-- The plain build instead takes a ShareLock on extension_version, blocking writes to it until the +-- build completes, which measured around a second per 350k active versions. At the rate extensions +-- are published that is an acceptable pause. lock_timeout bounds the worse failure mode: if a +-- long-running transaction already holds a conflicting lock on the table, this gives up rather than +-- parking every subsequent writer behind a build that is itself queued, and can be retried when the +-- table is quiet. +-- +-- To deploy with no write pause at all, create the index out of band first, where nothing else holds +-- a transaction open: +-- CREATE INDEX CONCURRENTLY extension_version_display_name_idx +-- ON public.extension_version (lower(btrim(display_name))) WHERE active; +-- IF NOT EXISTS then reduces this migration to a no-op. Note that a failed CONCURRENTLY build leaves +-- an invalid index behind which IF NOT EXISTS will not replace; drop it before retrying. + +SET LOCAL lock_timeout = '5s'; + +CREATE INDEX IF NOT EXISTS extension_version_display_name_idx + ON public.extension_version (lower(btrim(display_name))) + WHERE active; diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 3f0efd6b0..d0b10eece 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -3501,7 +3501,8 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( users, validator, extensionControl, - extensionScanService); + extensionScanService, + false); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java index 5e144c5e6..720a03f90 100644 --- a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.time.LocalDateTime; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -27,6 +28,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.util.Streamable; import org.eclipse.openvsx.ExtensionProcessor; import org.eclipse.openvsx.ExtensionValidator; @@ -46,7 +48,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -99,12 +103,17 @@ void setUp() throws Exception { users, validator, extensionControl, - scanService); + scanService, + false); // Lenient: not all tests need this mock org.mockito.Mockito.lenient() .when(extensionControl.getMaliciousExtensionIds()) .thenReturn(Collections.emptyList()); + + // Lenient: only the tests that reach the creation of an extension look the publisher's + // namespaces up, and the display name conflict lookup answers "unused" unless stubbed. + lenient().when(repositories.findMemberships(any(UserData.class))).thenReturn(Streamable.empty()); } @Test @@ -536,6 +545,329 @@ void shouldFailPreconditionsForRemovedVersion() { } } + @Test + void shouldRejectNewExtensionUsingTheDisplayNameOfAnotherExtension() throws IOException { + // Publishing under the exact display name of an existing extension is the cheapest way to be + // taken for it, so the package is rejected before any part of it is persisted. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var metadata = mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(null); + when(repositories.findActiveExtensionByDisplayName(eq("Demo OK"), any())) + .thenReturn(buildExtension("otherpublisher", "other-demo")); + + assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), false)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Display name 'Demo OK' is already used by") + .hasMessageContaining("otherpublisher.other-demo"); + + verify(entityManager, never()).persist(metadata); + verify(entityManager, never()).persist(any(Extension.class)); + } + } + + @Test + void shouldNotTreatTheOwnNamespacesOfThePublisherAsADisplayNameConflict() throws IOException { + // A publisher shipping two extensions under one display name impersonates nobody, so neither + // the namespace published to nor any other namespace they belong to can conflict. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + var membership = new NamespaceMembership(); + membership.setUser(user); + membership.setNamespace(buildNamespace("other-namespace-of-the-publisher")); + membership.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(null); + when(repositories.findMemberships(user)).thenReturn(Streamable.of(membership)); + + handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + var excludedNamespaces = ArgumentCaptor.forClass(Collection.class); + verify(repositories).findActiveExtensionByDisplayName(eq("Demo OK"), excludedNamespaces.capture()); + assertThat(excludedNamespaces.getValue()) + .containsExactlyInAnyOrder("publisher", "other-namespace-of-the-publisher"); + } + } + + @Test + void shouldNotCheckTheDisplayNameOfAVersionKeepingTheNameTheExtensionAlreadyShows() throws IOException { + // A routine version bump carries the name its extension already shows, so it adopts nothing and + // is not checked. This is what grandfathers the extensions that already share a display name + // with another -- the registry holds many such pairs, predating this check -- which would + // otherwise be left unable to publish anything at all. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + var existingExtension = buildExtension("publisher", "demo"); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(existingExtension); + when(repositories.findLatestVersion(existingExtension, null, false, true)) + .thenReturn(buildVersionShowing("Demo OK")); + + handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(repositories, never()).findActiveExtensionByDisplayName(anyString(), any()); + } + } + + @Test + void shouldRejectAVersionRenamingAnExistingExtensionOntoTheDisplayNameOfAnother() throws IOException { + // The escalation the new-extension check alone would leave open: enter the registry under a name + // of one's own, pass, then take the display name of a popular extension in the next version -- + // the manifest being the source of truth for the name the registry goes on to show. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var metadata = mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + var existingExtension = buildExtension("publisher", "demo"); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(existingExtension); + when(repositories.findLatestVersion(existingExtension, null, false, true)) + .thenReturn(buildVersionShowing("Some Name Of Its Own")); + when(repositories.findActiveExtensionByDisplayName(eq("Demo OK"), any())) + .thenReturn(buildExtension("otherpublisher", "other-demo")); + + assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), false)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Display name 'Demo OK' is already used by") + .hasMessageContaining("otherpublisher.other-demo"); + + verify(entityManager, never()).persist(metadata); + } + } + + @Test + void shouldAllowAVersionRenamingAnExistingExtensionToADisplayNameNobodyHolds() throws IOException { + // Renaming is legitimate; it is only rejected when the name being taken is another extension's. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + var existingExtension = buildExtension("publisher", "demo"); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(existingExtension); + when(repositories.findLatestVersion(existingExtension, null, false, true)) + .thenReturn(buildVersionShowing("Some Name Of Its Own")); + when(repositories.findActiveExtensionByDisplayName(eq("Demo OK"), any())).thenReturn(null); + + handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + // The rename was checked rather than waved through, and nothing held the name. + verify(repositories).findActiveExtensionByDisplayName(eq("Demo OK"), any()); + } + } + + @Test + void shouldNotTreatACasingOrWhitespaceOnlyDifferenceAsRenamingTheExtension() throws IOException { + // The conflict lookup normalises casing and surrounding whitespace away, so a version differing + // only there shows the name its extension already holds. Counting that as a rename would let a + // stray space break a publisher the unchanged-name path had grandfathered in. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var metadata = mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + metadata.setDisplayName(" demo ok "); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + var existingExtension = buildExtension("publisher", "demo"); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(existingExtension); + when(repositories.findLatestVersion(existingExtension, null, false, true)) + .thenReturn(buildVersionShowing("Demo OK")); + + handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(repositories, never()).findActiveExtensionByDisplayName(anyString(), any()); + } + } + + @Test + void shouldNotCheckTheDisplayNameWhenMirroringAnotherRegistry() throws IOException { + // A mirror has to end up with what its upstream holds, duplicate display names included: + // rejecting one protects nobody from an extension that is published upstream anyway, and would + // leave the mirror permanently missing it. + var mirroringHandler = new PublishExtensionVersionHandler( + config, + publishService, + integrityService, + entityManager, + repositories, + scheduler, + users, + validator, + extensionControl, + scanService, + true); + + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(processor.getPackageMetadata()).thenReturn( + new ExtensionProcessor.PackageMetadata("publisher", "demo", "2.0.0", "Demo OK")); + when(repositories.findExtensionForUpdate("demo", "publisher")).thenReturn(null); + + mirroringHandler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(repositories, never()).findActiveExtensionByDisplayName(anyString(), any()); + } + } + + @Test + void shouldFailPreconditionsWhenTheDisplayNameIsTakenByAnotherExtension() { + // Rejected up front rather than after scanning, so a package that cannot be published in the + // first place does not occupy the scanners. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(processor.getNamespace()).thenReturn("publisher"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("2.0.0"); + when(processor.getTargetPlatform()).thenReturn(TargetPlatform.NAME_UNIVERSAL); + when(processor.getDisplayName()).thenReturn("Demo OK"); + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(repositories.findVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, "demo", "publisher")) + .thenReturn(null); + when(repositories.findActiveExtensionByDisplayName(eq("Demo OK"), any())) + .thenReturn(buildExtension("otherpublisher", "other-demo")); + + assertThatThrownBy(() -> handler.checkPublishPreconditions(processor, token)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Display name 'Demo OK' is already used by"); + } + } + + @Test + void shouldPassPreconditionsForAFurtherVersionKeepingTheNameTheExtensionAlreadyShows() { + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(processor.getNamespace()).thenReturn("publisher"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("2.0.0"); + when(processor.getTargetPlatform()).thenReturn(TargetPlatform.NAME_UNIVERSAL); + when(processor.getDisplayName()).thenReturn("Demo OK"); + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(repositories.findVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, "demo", "publisher")) + .thenReturn(null); + when(repositories.findLatestVersion("publisher", "demo", null, false, true)) + .thenReturn(buildVersionShowing("Demo OK")); + + assertThatCode(() -> handler.checkPublishPreconditions(processor, token)).doesNotThrowAnyException(); + + verify(repositories, never()).findActiveExtensionByDisplayName(anyString(), any()); + } + } + + @Test + void shouldFailPreconditionsWhenAVersionRenamesAnExistingExtensionOntoATakenDisplayName() { + // The escalation path is rejected up front too, so a renaming package that cannot be published + // does not occupy the scanners either. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var namespace = buildNamespace("publisher"); + var user = new UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(processor.getNamespace()).thenReturn("publisher"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("2.0.0"); + when(processor.getTargetPlatform()).thenReturn(TargetPlatform.NAME_UNIVERSAL); + when(processor.getDisplayName()).thenReturn("Demo OK"); + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(repositories.findVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, "demo", "publisher")) + .thenReturn(null); + when(repositories.findLatestVersion("publisher", "demo", null, false, true)) + .thenReturn(buildVersionShowing("Some Name Of Its Own")); + when(repositories.findActiveExtensionByDisplayName(eq("Demo OK"), any())) + .thenReturn(buildExtension("otherpublisher", "other-demo")); + + assertThatThrownBy(() -> handler.checkPublishPreconditions(processor, token)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Display name 'Demo OK' is already used by") + .hasMessageContaining("otherpublisher.other-demo"); + } + } + private ExtensionVersion mockExtensionVersion( String namespace, String name, @@ -550,6 +882,9 @@ private ExtensionVersion mockExtensionVersion( when(processor.getIconPath()).thenReturn(iconPath); } + // Lenient: the tests that fail before an extension row is reached never read it + lenient().when(processor.getDisplayName()).thenReturn("Demo OK"); + var ev = new ExtensionVersion(); ev.setDisplayName("Demo OK"); ev.setVersion("2.0.0"); @@ -559,6 +894,20 @@ private ExtensionVersion mockExtensionVersion( return ev; } + /** The version the registry currently shows for an extension, for the rename comparison. */ + private ExtensionVersion buildVersionShowing(String displayName) { + var extVersion = new ExtensionVersion(); + extVersion.setDisplayName(displayName); + return extVersion; + } + + private Extension buildExtension(String namespaceName, String extensionName) { + var extension = new Extension(); + extension.setName(extensionName); + extension.setNamespace(buildNamespace(namespaceName)); + return extension; + } + private Namespace buildNamespace(String name) { var namespace = new Namespace(); namespace.setName(name); diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/ExtensionJooqRepositoryTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/ExtensionJooqRepositoryTest.java index 9f03abd61..45ed1ad41 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/ExtensionJooqRepositoryTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/ExtensionJooqRepositoryTest.java @@ -23,17 +23,13 @@ import org.eclipse.openvsx.AbstractPostgresContainerTest; import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.Namespace; import org.eclipse.openvsx.util.ExtensionId; +import org.eclipse.openvsx.util.TargetPlatform; import static org.assertj.core.api.Assertions.assertThat; -/** - * TOB-OVSX-37: {@code findFirstUnresolvedDependency} joined the extension table by name only, not - * constrained to the namespace resolved by the preceding join. A dependency naming an existing - * namespace that does not contain the named extension, where that extension name exists under a - * different namespace, was therefore incorrectly reported as resolved. - */ @SpringBootTest @Transactional class ExtensionJooqRepositoryTest extends AbstractPostgresContainerTest { @@ -44,6 +40,12 @@ class ExtensionJooqRepositoryTest extends AbstractPostgresContainerTest { @Autowired EntityManager em; + /** + * TOB-OVSX-37: {@code findFirstUnresolvedDependency} joined the extension table by name only, not + * constrained to the namespace resolved by the preceding join. A dependency naming an existing + * namespace that does not contain the named extension, where that extension name exists under a + * different namespace, was therefore incorrectly reported as resolved. + */ @Test void reportsDependencyAsUnresolvedWhenExtensionNameExistsOnlyInAnotherNamespace() { persistExtension("trustednamespace", "unrelated-extension"); @@ -93,7 +95,82 @@ void matchesNamespaceAndExtensionNameIgnoringCase() { assertThat(unresolved).isNull(); } - private void persistExtension(String namespaceName, String extensionName) { + @Test + void findsTheExtensionShowingTheGivenDisplayName() { + var squatted = persistExtension("dn-original-ns", "original-extension"); + persistVersion(squatted, "1.0.0", "Pretty Formatter", true); + + var conflict = repo.findActiveExtensionByDisplayName("Pretty Formatter", List.of("dn-publisher-ns")); + + assertThat(conflict).isNotNull(); + assertThat(conflict.getName()).isEqualTo("original-extension"); + assertThat(conflict.getNamespace().getName()).isEqualTo("dn-original-ns"); + } + + @Test + void matchesDisplayNamesDifferingOnlyInCaseOrSurroundingWhitespace() { + // Neither is visible when the two names are read side by side, so neither is enough to tell + // the extensions apart -- which is exactly what an impersonation would rely on. + var squatted = persistExtension("dn-case-ns", "original-extension"); + persistVersion(squatted, "1.0.0", "Pretty Formatter", true); + + assertThat(repo.findActiveExtensionByDisplayName("pretty formatter", List.of())).isNotNull(); + assertThat(repo.findActiveExtensionByDisplayName("PRETTY FORMATTER", List.of())).isNotNull(); + assertThat(repo.findActiveExtensionByDisplayName(" Pretty Formatter ", List.of())).isNotNull(); + } + + @Test + void reportsNoConflictForADisplayNameNobodyShows() { + var extension = persistExtension("dn-unused-ns", "original-extension"); + persistVersion(extension, "1.0.0", "Pretty Formatter", true); + + assertThat(repo.findActiveExtensionByDisplayName("Prettier Formatter", List.of())).isNull(); + assertThat(repo.findActiveExtensionByDisplayName("Pretty", List.of())).isNull(); + assertThat(repo.findActiveExtensionByDisplayName("", List.of())).isNull(); + assertThat(repo.findActiveExtensionByDisplayName(null, List.of())).isNull(); + } + + @Test + void skipsTheExcludedNamespaces() { + var extension = persistExtension("dn-excluded-ns", "original-extension"); + persistVersion(extension, "1.0.0", "Pretty Formatter", true); + + assertThat(repo.findActiveExtensionByDisplayName("Pretty Formatter", List.of("dn-excluded-ns"))).isNull(); + assertThat(repo.findActiveExtensionByDisplayName("Pretty Formatter", List.of("DN-Excluded-NS"))).isNull(); + assertThat(repo.findActiveExtensionByDisplayName("Pretty Formatter", List.of("dn-unrelated-ns"))).isNotNull(); + } + + @Test + void ignoresExtensionsAndVersionsThatAreNotPubliclyVisible() { + // A name nobody can see is a name nobody can be misled by, and rejecting a publication over one + // would hand out reservations on display names that no extension actually shows. + var inactiveVersion = persistExtension("dn-inactive-version-ns", "inactive-version-extension"); + persistVersion(inactiveVersion, "1.0.0", "Hidden Formatter", false); + + var inactiveExtension = persistExtension("dn-inactive-ext-ns", "inactive-extension", false); + persistVersion(inactiveExtension, "1.0.0", "Withdrawn Formatter", true); + + assertThat(repo.findActiveExtensionByDisplayName("Hidden Formatter", List.of())).isNull(); + assertThat(repo.findActiveExtensionByDisplayName("Withdrawn Formatter", List.of())).isNull(); + } + + @Test + void onlyConsidersTheDisplayNameOfTheLatestVersion() { + // The latest version is the one the registry shows, so a name an extension has moved away from + // is no longer taken, while the name it moved to is. + var renamed = persistExtension("dn-renamed-ns", "renamed-extension"); + persistVersion(renamed, "1.0.0", "Former Formatter", true); + persistVersion(renamed, "2.0.0", "Current Formatter", true); + + assertThat(repo.findActiveExtensionByDisplayName("Former Formatter", List.of())).isNull(); + assertThat(repo.findActiveExtensionByDisplayName("Current Formatter", List.of())).isNotNull(); + } + + private Extension persistExtension(String namespaceName, String extensionName) { + return persistExtension(namespaceName, extensionName, true); + } + + private Extension persistExtension(String namespaceName, String extensionName, boolean active) { var namespace = new Namespace(); namespace.setName(namespaceName); em.persist(namespace); @@ -101,7 +178,7 @@ private void persistExtension(String namespaceName, String extensionName) { var extension = new Extension(); extension.setName(extensionName); extension.setNamespace(namespace); - extension.setActive(true); + extension.setActive(active); extension.setDeprecated(false); extension.setDownloadable(true); extension.setPublishedDate(LocalDateTime.now()); @@ -111,5 +188,18 @@ private void persistExtension(String namespaceName, String extensionName) { // ExtensionJooqRepository queries run over the transaction's raw JDBC connection, bypassing // the persistence context, so pending inserts must be flushed before they become visible to it. em.flush(); + return extension; + } + + private void persistVersion(Extension extension, String version, String displayName, boolean active) { + var extVersion = new ExtensionVersion(); + extVersion.setExtension(extension); + extVersion.setVersion(version); + extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); + extVersion.setDisplayName(displayName); + extVersion.setActive(active); + extVersion.setTimestamp(LocalDateTime.now()); + em.persist(extVersion); + em.flush(); } } diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 487a483a5..3deb27ee2 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -363,6 +363,7 @@ void testExecuteQueries() { () -> repositories.expireAccessTokens(NOW), () -> repositories.findExpiringAccessTokensWithoutNotification(NOW, page), () -> repositories.updateExpiresTimeForLegacyAccessTokens(NOW), + () -> repositories.findActiveExtensionByDisplayName("displayName", List.of("namespaceName")), () -> repositories.findSimilarExtensionsByLevenshtein( "extensionName", "namespaceName", From d92db4ef0c97d053991763077714385436f29a02 Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Thu, 20 Aug 2026 15:14:40 -0400 Subject: [PATCH 3/3] fix: Update the Name Squatting changes to follow project conventions Assisted-by: Claude Opus 5 --- .../eclipse/openvsx/admin/AdminService.java | 450 ++++++++++++++++ .../openvsx/admin/NameSquattingAPI.java | 446 ++-------------- .../repositories/RepositoryService.java | 4 +- .../openvsx/admin/NameSquattingAPITest.java | 505 ++++++------------ .../admin/NameSquattingAdminServiceTest.java | 461 ++++++++++++++++ .../RepositoryServiceSmokeTest.java | 6 +- 6 files changed, 1116 insertions(+), 756 deletions(-) create mode 100644 server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAdminServiceTest.java diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index b4c6ead6b..1a3db354a 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -9,10 +9,14 @@ ********************************************************************************/ package org.eclipse.openvsx.admin; +import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -24,6 +28,7 @@ import org.apache.commons.lang3.StringUtils; import org.jobrunr.scheduling.JobRequestScheduler; import org.jobrunr.scheduling.cron.Cron; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.event.ApplicationStartedEvent; @@ -42,13 +47,23 @@ import org.eclipse.openvsx.entities.AdminStatistics; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionReview; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.ExtensionVersionState; import org.eclipse.openvsx.entities.Namespace; import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.ScanStatus; import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.ChangeNamespaceJson; import org.eclipse.openvsx.json.ExtensionJson; +import org.eclipse.openvsx.json.NameSquattingActionRequest; +import org.eclipse.openvsx.json.NameSquattingActionResponseJson; +import org.eclipse.openvsx.json.NameSquattingActionResultJson; +import org.eclipse.openvsx.json.NameSquattingCountsJson; +import org.eclipse.openvsx.json.NameSquattingFindingJson; +import org.eclipse.openvsx.json.NameSquattingFlagJson; +import org.eclipse.openvsx.json.NameSquattingFlagListJson; +import org.eclipse.openvsx.json.NameSquattingTargetJson; import org.eclipse.openvsx.json.NamespaceJson; import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.json.UserPublishInfoJson; @@ -66,6 +81,11 @@ import org.eclipse.openvsx.util.TimeUtil; import org.eclipse.openvsx.util.UrlUtil; +import static org.eclipse.openvsx.admin.NameSquattingAPI.NAME_SQUATTING_CHECK_TYPE; +import static org.eclipse.openvsx.admin.NameSquattingAPI.NAME_SQUATTING_STATE_DEACTIVATED; +import static org.eclipse.openvsx.admin.NameSquattingAPI.NAME_SQUATTING_STATE_PUBLISHED; +import static org.eclipse.openvsx.admin.NameSquattingAPI.NAME_SQUATTING_STATE_REJECTED; + import static org.eclipse.openvsx.entities.FileResource.CHANGELOG; import static org.eclipse.openvsx.entities.FileResource.DOWNLOAD; import static org.eclipse.openvsx.entities.FileResource.ICON; @@ -736,4 +756,434 @@ private void validateYearAndMonth(int year, int month) { throw new ErrorResultException("Combination of year and month lies in the future", HttpStatus.BAD_REQUEST); } } + + /** + * Which extensions to include, by what became of them after the check ran. All false means no + * filtering; see {@code NameSquattingFlagJson.state} for what each state means. + */ + public record ExtensionStateFilter( + boolean filterPublished, + boolean filterDeactivated, + boolean filterRejected + ) { + public boolean hasFilter() { + return filterPublished || filterDeactivated || filterRejected; + } + } + + /** + * List the extensions flagged by the name squatting check, one entry per extension. Findings are + * grouped per extension because the moderation actions apply to the extension as a whole. + */ + public NameSquattingFlagListJson getNameSquattingFlags( + @Nullable String publisher, + @Nullable String namespace, + @Nullable String name, + @Nullable List state, + @Nullable String dateDetectedFrom, + @Nullable String dateDetectedTo, + int size, + int offset, + @Nullable String sortOrder + ) throws ErrorResultException { + var normalizedPublisher = normalizeSearch(publisher); + var normalizedNamespace = normalizeSearch(namespace); + var normalizedName = normalizeSearch(name); + var stateFilter = parseStateFilter(state); + var detectedFrom = parseUtcDateTime(dateDetectedFrom, "dateDetectedFrom"); + var detectedTo = parseUtcDateTime(dateDetectedTo, "dateDetectedTo"); + var ascending = parseSortOrder(sortOrder); + + var totalSize = repositories.countFlaggedExtensions( + NAME_SQUATTING_CHECK_TYPE, + normalizedNamespace, + normalizedPublisher, + normalizedName, + detectedFrom, + detectedTo, + stateFilter); + + var keys = size == 0 + ? List.of() + : repositories.findFlaggedExtensionKeys( + NAME_SQUATTING_CHECK_TYPE, + normalizedNamespace, + normalizedPublisher, + normalizedName, + detectedFrom, + detectedTo, + stateFilter, + ascending, + size, + offset); + + var flags = new ArrayList(); + for (var key : keys) { + var flag = toNameSquattingFlagJson(key, detectedFrom, detectedTo); + if (flag != null) { + flags.add(flag); + } + } + + var result = new NameSquattingFlagListJson(); + result.setOffset(offset); + result.setTotalSize((int) totalSize); + result.setFlags(flags); + return result; + } + + /** + * Count the extensions flagged by the name squatting check, broken down by what became of them. + */ + public NameSquattingCountsJson getNameSquattingCounts( + @Nullable String publisher, + @Nullable String namespace, + @Nullable String name, + @Nullable String dateDetectedFrom, + @Nullable String dateDetectedTo + ) throws ErrorResultException { + var normalizedPublisher = normalizeSearch(publisher); + var normalizedNamespace = normalizeSearch(namespace); + var normalizedName = normalizeSearch(name); + var detectedFrom = parseUtcDateTime(dateDetectedFrom, "dateDetectedFrom"); + var detectedTo = parseUtcDateTime(dateDetectedTo, "dateDetectedTo"); + + var counts = new NameSquattingCountsJson(); + counts.setTotal( + countFlaggedExtensions(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, null)); + counts.setPublished( + countFlaggedExtensions(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, new ExtensionStateFilter(true, false, false))); + counts.setDeactivated( + countFlaggedExtensions(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, new ExtensionStateFilter(false, true, false))); + counts.setRejected( + countFlaggedExtensions(normalizedNamespace, normalizedPublisher, normalizedName, + detectedFrom, detectedTo, new ExtensionStateFilter(false, false, true))); + return counts; + } + + /** + * Clear the name squatting findings recorded for the requested extensions, for use when an + * administrator judges the match to be a false positive. + *

+ * This removes the failure records, so the extension no longer shows up as flagged. The audit + * record of the check having run is kept in the scan check results, and the action itself is + * written to the admin log. + */ + public NameSquattingActionResponseJson clearNameSquattingFindings( + UserData adminUser, + NameSquattingActionRequest request + ) throws ErrorResultException { + var results = new ArrayList(); + for (var target : requireNameSquattingTargets(request)) { + results.add(clearNameSquattingFindings(adminUser, target)); + } + return toNameSquattingActionResponse(results); + } + + /** + * Soft-delete the requested flagged extensions, for use when the match turns out to be a real + * attempt at squatting a name. + *

+ * Every active version is deactivated, which makes the extension unavailable while keeping its + * records and reserving its version identities. Extensions whose publication was blocked by the + * check were never created and cannot be deleted. + */ + public NameSquattingActionResponseJson deleteNameSquattingExtensions( + UserData adminUser, + NameSquattingActionRequest request + ) throws ErrorResultException { + var results = new ArrayList(); + for (var target : requireNameSquattingTargets(request)) { + results.add(deleteNameSquattingExtension(adminUser, target)); + } + return toNameSquattingActionResponse(results); + } + + private NameSquattingActionResultJson clearNameSquattingFindings( + UserData adminUser, + NameSquattingTargetJson target + ) { + var namespaceName = target.getNamespace(); + var extensionName = target.getExtension(); + try { + var cleared = repositories + .deleteValidationFailures(NAME_SQUATTING_CHECK_TYPE, namespaceName, extensionName); + if (cleared == 0) { + return NameSquattingActionResultJson.failure( + namespaceName, + extensionName, + "No name squatting findings are recorded for this extension"); + } + + var message = String.format( + "Cleared %d name squatting finding%s for extension %s.%s as a false positive", + cleared, + cleared == 1 ? "" : "s", + namespaceName, + extensionName); + logs.logAction(adminUser, ResultJson.success(message)); + + return NameSquattingActionResultJson.success(namespaceName, extensionName, message); + } catch (ErrorResultException exc) { + return NameSquattingActionResultJson.failure(namespaceName, extensionName, exc.getMessage()); + } + } + + private NameSquattingActionResultJson deleteNameSquattingExtension( + UserData adminUser, + NameSquattingTargetJson target + ) { + var namespaceName = target.getNamespace(); + var extensionName = target.getExtension(); + + var extension = repositories.findExtension(extensionName, namespaceName); + if (extension == null) { + return NameSquattingActionResultJson.failure( + namespaceName, + extensionName, + "Extension does not exist, its publication was blocked by the check"); + } + + var targetVersions = activeTargetVersions(extension); + if (targetVersions.length == 0) { + return NameSquattingActionResultJson.failure( + namespaceName, + extensionName, + "Extension has no active versions left to deactivate"); + } + + try { + var result = deleteExtensionNoWait( + adminUser, + extension.getNamespace().getName(), + extension.getName(), + targetVersions); + if (result != null && result.getError() != null) { + return NameSquattingActionResultJson.failure(namespaceName, extensionName, result.getError()); + } + + var message = String.format( + "Deactivated %d version%s of extension %s.%s flagged for name squatting", + targetVersions.length, + targetVersions.length == 1 ? "" : "s", + extension.getNamespace().getName(), + extension.getName()); + logs.logAction(adminUser, ResultJson.success(message)); + + return NameSquattingActionResultJson.success(namespaceName, extensionName, message); + } catch (ErrorResultException exc) { + return NameSquattingActionResultJson.failure(namespaceName, extensionName, exc.getMessage()); + } + } + + /** + * Build the response row for one {@code /} key, or null when its findings + * were cleared between listing the keys and reading them back. + */ + private @Nullable NameSquattingFlagJson toNameSquattingFlagJson( + String key, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo + ) { + var separator = key.indexOf('/'); + if (separator < 0) { + return null; + } + var namespaceName = key.substring(0, separator); + var extensionName = key.substring(separator + 1); + + var failures = repositories.findValidationFailures( + NAME_SQUATTING_CHECK_TYPE, + namespaceName, + extensionName, + detectedFrom, + detectedTo); + if (failures.isEmpty()) { + return null; + } + + // Failures come back newest first, so the first one carries the most recent metadata. + var latestScan = failures.getFirst().getScan(); + var extension = repositories.findExtension(extensionName, namespaceName); + + var json = new NameSquattingFlagJson(); + json.setNamespace(latestScan.getNamespaceName()); + json.setExtensionName(latestScan.getExtensionName()); + json.setDisplayName( + latestScan.getExtensionDisplayName() != null + ? latestScan.getExtensionDisplayName() + : latestScan.getExtensionName()); + json.setPublisher(latestScan.getPublisher()); + json.setPublisherUrl(latestScan.getPublisherUrl()); + json.setFindingCount(failures.size()); + json.setDateLastDetected(TimeUtil.toUTCString(failures.getFirst().getDetectedAt())); + json.setDateFirstDetected(TimeUtil.toUTCString(failures.getLast().getDetectedAt())); + json.setFindings(failures.stream().map(this::toNameSquattingFindingJson).toList()); + + if (extension == null) { + json.setState(NAME_SQUATTING_STATE_REJECTED); + json.setActiveVersionCount(0); + } else { + var activeVersions = (int) repositories.findActiveVersions(extension).stream().count(); + json.setActiveVersionCount(activeVersions); + json.setState( + extension.isActive() && activeVersions > 0 + ? NAME_SQUATTING_STATE_PUBLISHED + : NAME_SQUATTING_STATE_DEACTIVATED); + } + + return json; + } + + private NameSquattingFindingJson toNameSquattingFindingJson(ExtensionValidationFailure failure) { + var scan = failure.getScan(); + var json = new NameSquattingFindingJson(); + json.setId(String.valueOf(failure.getId())); + json.setScanId(String.valueOf(scan.getId())); + json.setVersion(scan.getExtensionVersion()); + json.setTargetPlatform(scan.getTargetPlatform()); + json.setScanStatus(formatScanStatus(scan.getStatus())); + json.setRuleName(failure.getRuleName()); + json.setReason(failure.getValidationFailureReason()); + json.setDateDetected(TimeUtil.toUTCString(failure.getDetectedAt())); + json.setEnforcedFlag(failure.isEnforced()); + return json; + } + + private TargetPlatformVersion[] activeTargetVersions(Extension extension) { + return repositories.findActiveVersions(extension).stream() + .map(version -> new TargetPlatformVersion(version.getTargetPlatform(), version.getVersion())) + .distinct() + .toArray(TargetPlatformVersion[]::new); + } + + private int countFlaggedExtensions( + @Nullable String namespace, + @Nullable String publisher, + @Nullable String name, + @Nullable LocalDateTime detectedFrom, + @Nullable LocalDateTime detectedTo, + @Nullable ExtensionStateFilter stateFilter + ) { + return (int) repositories.countFlaggedExtensions( + NAME_SQUATTING_CHECK_TYPE, + namespace, + publisher, + name, + detectedFrom, + detectedTo, + stateFilter); + } + + private List requireNameSquattingTargets(NameSquattingActionRequest request) { + var targets = request.getTargets(); + if (targets == null || targets.isEmpty()) { + throw new ErrorResultException("At least one extension is required", HttpStatus.BAD_REQUEST); + } + for (var target : targets) { + if (target == null + || target.getNamespace() == null || target.getNamespace().isBlank() + || target.getExtension() == null || target.getExtension().isBlank()) { + throw new ErrorResultException( + "Each extension must have a namespace and an extension name", + HttpStatus.BAD_REQUEST); + } + } + return targets; + } + + private NameSquattingActionResponseJson toNameSquattingActionResponse( + List results + ) { + var successful = (int) results.stream().filter(NameSquattingActionResultJson::isSuccess).count(); + + var response = new NameSquattingActionResponseJson(); + response.setProcessed(results.size()); + response.setSuccessful(successful); + response.setFailed(results.size() - successful); + response.setResults(results); + return response; + } + + private @Nullable ExtensionStateFilter parseStateFilter(@Nullable List state) { + if (state == null || state.isEmpty()) { + return null; + } + + var published = false; + var deactivated = false; + var rejected = false; + for (var raw : state) { + if (raw == null || raw.isBlank()) { + continue; + } + for (var token : raw.split(",")) { + if (token.isBlank()) { + continue; + } + switch (token.trim().toUpperCase(Locale.ROOT)) { + case NAME_SQUATTING_STATE_PUBLISHED -> published = true; + case NAME_SQUATTING_STATE_DEACTIVATED -> deactivated = true; + case NAME_SQUATTING_STATE_REJECTED -> rejected = true; + default -> throw new ErrorResultException( + "Unknown state filter: " + token.trim(), + HttpStatus.BAD_REQUEST); + } + } + } + + var filter = new ExtensionStateFilter(published, deactivated, rejected); + return filter.hasFilter() ? filter : null; + } + + private @Nullable String normalizeSearch(@Nullable String search) { + if (search == null || search.isBlank()) { + return null; + } + return search.trim(); + } + + private boolean parseSortOrder(@Nullable String sortOrder) { + if (sortOrder == null) { + return false; + } + return switch (sortOrder.toLowerCase(Locale.ROOT)) { + case "asc" -> true; + case "desc" -> false; + default -> + throw new ErrorResultException("Unsupported sortOrder value: " + sortOrder, HttpStatus.BAD_REQUEST); + }; + } + + private @Nullable LocalDateTime parseUtcDateTime(@Nullable String raw, String paramName) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return TimeUtil.fromUTCString(raw); + } catch (Exception e) { + throw new ErrorResultException( + "Invalid ISO date-time for parameter '" + paramName + "': " + raw, + HttpStatus.BAD_REQUEST); + } + } + + /** + * Formats the scan status the same way the scan API does, so the admin dashboard shows one + * vocabulary across both views. + */ + private String formatScanStatus(ScanStatus status) { + return switch (status) { + case STARTED -> "STARTED"; + case VALIDATING -> "VALIDATING"; + case SCANNING -> "SCANNING"; + case PASSED -> "PASSED"; + case QUARANTINED -> "QUARANTINED"; + case REJECTED -> "AUTO REJECTED"; + case ERRORED -> "ERROR"; + }; + } } diff --git a/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java index fa8c723c8..18a1df4f3 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java @@ -12,10 +12,25 @@ ********************************************************************************/ package org.eclipse.openvsx.admin; -import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; + +import org.eclipse.openvsx.json.NameSquattingActionRequest; +import org.eclipse.openvsx.json.NameSquattingActionResponseJson; +import org.eclipse.openvsx.json.NameSquattingCountsJson; +import org.eclipse.openvsx.json.NameSquattingFlagListJson; +import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.settings.MutatingOperation; +import org.eclipse.openvsx.util.ErrorResultException; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -27,25 +42,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import org.jspecify.annotations.Nullable; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import org.eclipse.openvsx.entities.Extension; -import org.eclipse.openvsx.entities.ExtensionValidationFailure; -import org.eclipse.openvsx.entities.ScanStatus; -import org.eclipse.openvsx.entities.UserData; -import org.eclipse.openvsx.json.*; -import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.search.SimilarityCheckService; -import org.eclipse.openvsx.settings.MutatingOperation; -import org.eclipse.openvsx.util.ErrorResultException; -import org.eclipse.openvsx.util.LogService; -import org.eclipse.openvsx.util.TargetPlatformVersion; -import org.eclipse.openvsx.util.TimeUtil; /** * REST API for moderating extensions that failed the name squatting publisher check. @@ -69,25 +65,21 @@ ) public class NameSquattingAPI { - private static final String CHECK_TYPE = SimilarityCheckService.CHECK_TYPE; - - /** The extension is live: it exists and has at least one active version. */ - private static final String STATE_PUBLISHED = "PUBLISHED"; + public static final String NAME_SQUATTING_CHECK_TYPE = SimilarityCheckService.CHECK_TYPE; - /** The extension exists but all of its versions have been deactivated. */ - private static final String STATE_DEACTIVATED = "DEACTIVATED"; + /** The flagged extension is live: it exists and has at least one active version. */ + public static final String NAME_SQUATTING_STATE_PUBLISHED = "PUBLISHED"; - /** Publication was blocked by the check, so the extension was never created. */ - private static final String STATE_REJECTED = "REJECTED"; + /** The flagged extension exists but all of its versions have been deactivated. */ + public static final String NAME_SQUATTING_STATE_DEACTIVATED = "DEACTIVATED"; - private final RepositoryService repositories; + /** Publication was blocked by the name squatting check, so the extension was never created. */ + public static final String NAME_SQUATTING_STATE_REJECTED = "REJECTED"; + private final AdminService admins; - private final LogService logs; - public NameSquattingAPI(RepositoryService repositories, AdminService admins, LogService logs) { - this.repositories = repositories; + public NameSquattingAPI(AdminService admins) { this.admins = admins; - this.logs = logs; } /** @@ -160,50 +152,16 @@ public ResponseEntity getFlaggedExtensions( try { admins.checkAdminUser(); - var normalizedPublisher = normalizeSearch(publisher); - var normalizedNamespace = normalizeSearch(namespace); - var normalizedName = normalizeSearch(name); - var stateFilter = parseStateFilter(state); - var detectedFrom = parseUtcDateTime(dateDetectedFrom, "dateDetectedFrom"); - var detectedTo = parseUtcDateTime(dateDetectedTo, "dateDetectedTo"); - var ascending = normalizeSortOrder(sortOrder); - - var totalSize = repositories.countFlaggedExtensions( - CHECK_TYPE, - normalizedNamespace, - normalizedPublisher, - normalizedName, - detectedFrom, - detectedTo, - stateFilter); - - var keys = size == 0 - ? List.of() - : repositories.findFlaggedExtensionKeys( - CHECK_TYPE, - normalizedNamespace, - normalizedPublisher, - normalizedName, - detectedFrom, - detectedTo, - stateFilter, - ascending, - size, - offset); - - var flags = new ArrayList(); - for (var key : keys) { - var flag = toFlagJson(key, detectedFrom, detectedTo); - if (flag != null) { - flags.add(flag); - } - } - - var result = new NameSquattingFlagListJson(); - result.setOffset(offset); - result.setTotalSize((int) totalSize); - result.setFlags(flags); - + var result = admins.getNameSquattingFlags( + publisher, + namespace, + name, + state, + dateDetectedFrom, + dateDetectedTo, + size, + offset, + sortOrder); return ResponseEntity.ok(result); } catch (ErrorResultException exc) { return exc.toResponseEntity(NameSquattingFlagListJson.class); @@ -248,29 +206,8 @@ public ResponseEntity getCounts( try { admins.checkAdminUser(); - var normalizedPublisher = normalizeSearch(publisher); - var normalizedNamespace = normalizeSearch(namespace); - var normalizedName = normalizeSearch(name); - var detectedFrom = parseUtcDateTime(dateDetectedFrom, "dateDetectedFrom"); - var detectedTo = parseUtcDateTime(dateDetectedTo, "dateDetectedTo"); - - var counts = new NameSquattingCountsJson(); - counts.setTotal( - countWithState(normalizedNamespace, normalizedPublisher, normalizedName, - detectedFrom, detectedTo, null)); - counts.setPublished( - countWithState(normalizedNamespace, normalizedPublisher, normalizedName, - detectedFrom, detectedTo, - new ExtensionStateFilter(true, false, false))); - counts.setDeactivated( - countWithState(normalizedNamespace, normalizedPublisher, normalizedName, - detectedFrom, detectedTo, - new ExtensionStateFilter(false, true, false))); - counts.setRejected( - countWithState(normalizedNamespace, normalizedPublisher, normalizedName, - detectedFrom, detectedTo, - new ExtensionStateFilter(false, false, true))); - + var counts = admins + .getNameSquattingCounts(publisher, namespace, name, dateDetectedFrom, dateDetectedTo); return ResponseEntity.ok(counts); } catch (ErrorResultException exc) { return exc.toResponseEntity(NameSquattingCountsJson.class); @@ -311,14 +248,7 @@ public ResponseEntity clearFindings( ) { try { var adminUser = admins.checkAdminUser(); - var targets = requireTargets(request); - - var results = new ArrayList(); - for (var target : targets) { - results.add(clearFindings(adminUser, target)); - } - - return ResponseEntity.ok(toActionResponse(results)); + return ResponseEntity.ok(admins.clearNameSquattingFindings(adminUser, request)); } catch (ErrorResultException exc) { return exc.toResponseEntity(NameSquattingActionResponseJson.class); } @@ -358,303 +288,9 @@ public ResponseEntity deleteExtensions( ) { try { var adminUser = admins.checkAdminUser(); - var targets = requireTargets(request); - - var results = new ArrayList(); - for (var target : targets) { - results.add(deleteExtension(adminUser, target)); - } - - return ResponseEntity.ok(toActionResponse(results)); + return ResponseEntity.ok(admins.deleteNameSquattingExtensions(adminUser, request)); } catch (ErrorResultException exc) { return exc.toResponseEntity(NameSquattingActionResponseJson.class); } } - - private NameSquattingActionResultJson clearFindings(UserData adminUser, NameSquattingTargetJson target) { - var namespaceName = target.getNamespace(); - var extensionName = target.getExtension(); - try { - var cleared = repositories.deleteValidationFailures(CHECK_TYPE, namespaceName, extensionName); - if (cleared == 0) { - return NameSquattingActionResultJson.failure( - namespaceName, - extensionName, - "No name squatting findings are recorded for this extension"); - } - - var message = String.format( - "Cleared %d name squatting finding%s for extension %s.%s as a false positive", - cleared, - cleared == 1 ? "" : "s", - namespaceName, - extensionName); - logs.logAction(adminUser, ResultJson.success(message)); - - return NameSquattingActionResultJson.success(namespaceName, extensionName, message); - } catch (ErrorResultException exc) { - return NameSquattingActionResultJson.failure(namespaceName, extensionName, exc.getMessage()); - } - } - - private NameSquattingActionResultJson deleteExtension(UserData adminUser, NameSquattingTargetJson target) { - var namespaceName = target.getNamespace(); - var extensionName = target.getExtension(); - - var extension = repositories.findExtension(extensionName, namespaceName); - if (extension == null) { - return NameSquattingActionResultJson.failure( - namespaceName, - extensionName, - "Extension does not exist, its publication was blocked by the check"); - } - - var targetVersions = activeTargetVersions(extension); - if (targetVersions.length == 0) { - return NameSquattingActionResultJson.failure( - namespaceName, - extensionName, - "Extension has no active versions left to deactivate"); - } - - try { - var result = admins.deleteExtensionNoWait( - adminUser, - extension.getNamespace().getName(), - extension.getName(), - targetVersions); - if (result != null && result.getError() != null) { - return NameSquattingActionResultJson.failure(namespaceName, extensionName, result.getError()); - } - - var message = String.format( - "Deactivated %d version%s of extension %s.%s flagged for name squatting", - targetVersions.length, - targetVersions.length == 1 ? "" : "s", - extension.getNamespace().getName(), - extension.getName()); - logs.logAction(adminUser, ResultJson.success(message)); - - return NameSquattingActionResultJson.success(namespaceName, extensionName, message); - } catch (ErrorResultException exc) { - return NameSquattingActionResultJson.failure(namespaceName, extensionName, exc.getMessage()); - } - } - - /** - * Build the response row for one {@code /} key, or null when its findings - * were cleared between listing the keys and reading them back. - */ - private @Nullable NameSquattingFlagJson toFlagJson( - String key, - @Nullable LocalDateTime detectedFrom, - @Nullable LocalDateTime detectedTo - ) { - var separator = key.indexOf('/'); - if (separator < 0) { - return null; - } - var namespaceName = key.substring(0, separator); - var extensionName = key.substring(separator + 1); - - var failures = repositories.findValidationFailures( - CHECK_TYPE, - namespaceName, - extensionName, - detectedFrom, - detectedTo); - if (failures.isEmpty()) { - return null; - } - - // Failures come back newest first, so the first one carries the most recent metadata. - var latestScan = failures.getFirst().getScan(); - var extension = repositories.findExtension(extensionName, namespaceName); - - var json = new NameSquattingFlagJson(); - json.setNamespace(latestScan.getNamespaceName()); - json.setExtensionName(latestScan.getExtensionName()); - json.setDisplayName( - latestScan.getExtensionDisplayName() != null - ? latestScan.getExtensionDisplayName() - : latestScan.getExtensionName()); - json.setPublisher(latestScan.getPublisher()); - json.setPublisherUrl(latestScan.getPublisherUrl()); - json.setFindingCount(failures.size()); - json.setDateLastDetected(TimeUtil.toUTCString(failures.getFirst().getDetectedAt())); - json.setDateFirstDetected(TimeUtil.toUTCString(failures.getLast().getDetectedAt())); - json.setFindings(failures.stream().map(this::toFindingJson).toList()); - - if (extension == null) { - json.setState(STATE_REJECTED); - json.setActiveVersionCount(0); - } else { - var activeVersions = (int) repositories.findActiveVersions(extension).stream().count(); - json.setActiveVersionCount(activeVersions); - json.setState(extension.isActive() && activeVersions > 0 ? STATE_PUBLISHED : STATE_DEACTIVATED); - } - - return json; - } - - private NameSquattingFindingJson toFindingJson(ExtensionValidationFailure failure) { - var scan = failure.getScan(); - var json = new NameSquattingFindingJson(); - json.setId(String.valueOf(failure.getId())); - json.setScanId(String.valueOf(scan.getId())); - json.setVersion(scan.getExtensionVersion()); - json.setTargetPlatform(scan.getTargetPlatform()); - json.setScanStatus(formatScanStatus(scan.getStatus())); - json.setRuleName(failure.getRuleName()); - json.setReason(failure.getValidationFailureReason()); - json.setDateDetected(TimeUtil.toUTCString(failure.getDetectedAt())); - json.setEnforcedFlag(failure.isEnforced()); - return json; - } - - private TargetPlatformVersion[] activeTargetVersions(Extension extension) { - return repositories.findActiveVersions(extension).stream() - .map(version -> new TargetPlatformVersion(version.getTargetPlatform(), version.getVersion())) - .distinct() - .toArray(TargetPlatformVersion[]::new); - } - - private int countWithState( - @Nullable String namespace, - @Nullable String publisher, - @Nullable String name, - @Nullable LocalDateTime detectedFrom, - @Nullable LocalDateTime detectedTo, - @Nullable ExtensionStateFilter stateFilter - ) { - return (int) repositories.countFlaggedExtensions( - CHECK_TYPE, - namespace, - publisher, - name, - detectedFrom, - detectedTo, - stateFilter); - } - - private List requireTargets(NameSquattingActionRequest request) { - var targets = request.getTargets(); - if (targets == null || targets.isEmpty()) { - throw new ErrorResultException("At least one extension is required", HttpStatus.BAD_REQUEST); - } - for (var target : targets) { - if (target == null - || target.getNamespace() == null || target.getNamespace().isBlank() - || target.getExtension() == null || target.getExtension().isBlank()) { - throw new ErrorResultException( - "Each extension must have a namespace and an extension name", - HttpStatus.BAD_REQUEST); - } - } - return targets; - } - - private NameSquattingActionResponseJson toActionResponse(List results) { - var successful = (int) results.stream().filter(NameSquattingActionResultJson::isSuccess).count(); - - var response = new NameSquattingActionResponseJson(); - response.setProcessed(results.size()); - response.setSuccessful(successful); - response.setFailed(results.size() - successful); - response.setResults(results); - return response; - } - - /** - * Which extensions to include, by what became of them after the check ran. All false means no - * filtering; see {@code NameSquattingFlagJson.state} for what each state means. - */ - public record ExtensionStateFilter( - boolean filterPublished, - boolean filterDeactivated, - boolean filterRejected - ) { - public boolean hasFilter() { - return filterPublished || filterDeactivated || filterRejected; - } - } - - private @Nullable ExtensionStateFilter parseStateFilter(@Nullable List state) { - if (state == null || state.isEmpty()) { - return null; - } - - var published = false; - var deactivated = false; - var rejected = false; - for (var raw : state) { - if (raw == null || raw.isBlank()) { - continue; - } - for (var token : raw.split(",")) { - if (token.isBlank()) { - continue; - } - switch (token.trim().toUpperCase(Locale.ROOT)) { - case STATE_PUBLISHED -> published = true; - case STATE_DEACTIVATED -> deactivated = true; - case STATE_REJECTED -> rejected = true; - default -> throw new ErrorResultException( - "Unknown state filter: " + token.trim(), - HttpStatus.BAD_REQUEST); - } - } - } - - var filter = new ExtensionStateFilter(published, deactivated, rejected); - return filter.hasFilter() ? filter : null; - } - - private @Nullable String normalizeSearch(@Nullable String search) { - if (search == null || search.isBlank()) { - return null; - } - return search.trim(); - } - - private boolean normalizeSortOrder(@Nullable String sortOrder) { - if (sortOrder == null) { - return false; - } - return switch (sortOrder.toLowerCase(Locale.ROOT)) { - case "asc" -> true; - case "desc" -> false; - default -> - throw new ErrorResultException("Unsupported sortOrder value: " + sortOrder, HttpStatus.BAD_REQUEST); - }; - } - - private @Nullable LocalDateTime parseUtcDateTime(@Nullable String raw, String paramName) { - if (raw == null || raw.isBlank()) { - return null; - } - try { - return TimeUtil.fromUTCString(raw); - } catch (Exception e) { - throw new ErrorResultException( - "Invalid ISO date-time for parameter '" + paramName + "': " + raw, - HttpStatus.BAD_REQUEST); - } - } - - /** - * Formats the scan status the same way the scan API does, so the admin dashboard shows one - * vocabulary across both views. - */ - private String formatScanStatus(ScanStatus status) { - return switch (status) { - case STARTED -> "STARTED"; - case VALIDATING -> "VALIDATING"; - case SCANNING -> "SCANNING"; - case PASSED -> "PASSED"; - case QUARANTINED -> "QUARANTINED"; - case REJECTED -> "AUTO REJECTED"; - case ERRORED -> "ERROR"; - }; - } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 04191265b..13d0a471c 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -1418,7 +1418,7 @@ public List findFlaggedExtensionKeys( @Nullable String name, @Nullable LocalDateTime detectedFrom, @Nullable LocalDateTime detectedTo, - org.eclipse.openvsx.admin.NameSquattingAPI.@Nullable ExtensionStateFilter stateFilter, + org.eclipse.openvsx.admin.AdminService.@Nullable ExtensionStateFilter stateFilter, boolean ascending, int limit, int offset @@ -1450,7 +1450,7 @@ public long countFlaggedExtensions( @Nullable String name, @Nullable LocalDateTime detectedFrom, @Nullable LocalDateTime detectedTo, - org.eclipse.openvsx.admin.NameSquattingAPI.@Nullable ExtensionStateFilter stateFilter + org.eclipse.openvsx.admin.AdminService.@Nullable ExtensionStateFilter stateFilter ) { var applyStateFilter = stateFilter != null && stateFilter.hasFilter(); return extensionValidationFailureRepo.countFlaggedExtensions( diff --git a/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java index c2e0861cf..e9af4266e 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java @@ -12,7 +12,6 @@ ********************************************************************************/ package org.eclipse.openvsx.admin; -import java.time.LocalDateTime; import java.util.List; import io.micrometer.core.instrument.MeterRegistry; @@ -21,30 +20,22 @@ import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.data.util.Streamable; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import org.eclipse.openvsx.entities.Extension; -import org.eclipse.openvsx.entities.ExtensionScan; -import org.eclipse.openvsx.entities.ExtensionValidationFailure; -import org.eclipse.openvsx.entities.ExtensionVersion; -import org.eclipse.openvsx.entities.Namespace; -import org.eclipse.openvsx.entities.ScanStatus; import org.eclipse.openvsx.entities.UserData; -import org.eclipse.openvsx.json.ResultJson; -import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.json.NameSquattingActionResponseJson; +import org.eclipse.openvsx.json.NameSquattingActionResultJson; +import org.eclipse.openvsx.json.NameSquattingCountsJson; +import org.eclipse.openvsx.json.NameSquattingFlagJson; +import org.eclipse.openvsx.json.NameSquattingFlagListJson; import org.eclipse.openvsx.util.ErrorResultException; -import org.eclipse.openvsx.util.LogService; -import org.eclipse.openvsx.util.TargetPlatformVersion; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -53,6 +44,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +/** + * Tests for the HTTP layer of {@link NameSquattingAPI}: request mapping, parameter binding and + * validation, the admin check, and serialization of what {@link AdminService} returns. The + * moderation logic itself is covered by {@link NameSquattingAdminServiceTest}. + */ @WebMvcTest( value = NameSquattingAPI.class, excludeAutoConfiguration = { OAuth2ClientWebSecurityAutoConfiguration.class } @@ -60,91 +56,99 @@ @AutoConfigureMockMvc(addFilters = false) class NameSquattingAPITest { - private static final String CHECK_TYPE = "NAME_SQUATTING"; - - private static final LocalDateTime FIRST_DETECTED = LocalDateTime.of(2026, 1, 5, 9, 30); - - private static final LocalDateTime LAST_DETECTED = LocalDateTime.of(2026, 2, 11, 14, 0); + private static final String TARGETS = "{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}"; @Autowired MockMvc mockMvc; - @MockitoBean - RepositoryService repositories; - @MockitoBean AdminService admins; - @MockitoBean - LogService logs; - @MockitoBean MeterRegistry meterRegistry; @Test - void getFlaggedExtensions_groups_findings_per_extension() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - stubFlaggedExtension("ns", "ext"); - - var extension = TestData.extension("ns", "ext", true); - when(repositories.findExtension("ext", "ns")).thenReturn(extension); - when(repositories.findActiveVersions(extension)) - .thenReturn(Streamable.of(TestData.version("universal", "1.0.0"))); + void getFlaggedExtensions_passes_the_query_on_and_returns_the_flags() throws Exception { + when(admins.checkAdminUser()).thenReturn(adminUser()); + when( + admins.getNameSquattingFlags( + any(), + any(), + any(), + any(), + any(), + any(), + any(Integer.class), + any(Integer.class), + any())) + .thenReturn(flagList()); - mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) + mockMvc.perform( + get("/admin/name-squatting") + .param("publisher", "publisher") + .param("namespace", "ns") + .param("name", "ext") + .param("state", "PUBLISHED,DEACTIVATED") + .param("dateDetectedFrom", "2026-01-01T00:00Z") + .param("dateDetectedTo", "2026-02-01T00:00Z") + .param("size", "25") + .param("offset", "50") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalSize").value(3)) - .andExpect(jsonPath("$.offset").value(0)) + .andExpect(jsonPath("$.offset").value(50)) .andExpect(jsonPath("$.flags.length()").value(1)) .andExpect(jsonPath("$.flags[0].namespace").value("ns")) .andExpect(jsonPath("$.flags[0].extensionName").value("ext")) - .andExpect(jsonPath("$.flags[0].displayName").value("Extension Display Name")) - .andExpect(jsonPath("$.flags[0].publisher").value("publisher")) - .andExpect(jsonPath("$.flags[0].state").value("PUBLISHED")) - .andExpect(jsonPath("$.flags[0].activeVersionCount").value(1)) - .andExpect(jsonPath("$.flags[0].findingCount").value(2)) - .andExpect(jsonPath("$.flags[0].findings.length()").value(2)) - // Findings come back newest first, so the dates bracket the whole history. - .andExpect(jsonPath("$.flags[0].findings[0].version").value("2.0.0")) - .andExpect(jsonPath("$.flags[0].findings[0].scanStatus").value("PASSED")) - .andExpect(jsonPath("$.flags[0].findings[0].enforcedFlag").value(false)) - .andExpect(jsonPath("$.flags[0].findings[1].version").value("1.0.0")) - .andExpect(jsonPath("$.flags[0].dateLastDetected").value("2026-02-11T14:00Z")) - .andExpect(jsonPath("$.flags[0].dateFirstDetected").value("2026-01-05T09:30Z")); - } - - @Test - void getFlaggedExtensions_reports_rejected_when_extension_was_never_created() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - stubFlaggedExtension("ns", "ext"); - - // Publication was blocked by an enforced check, so no extension exists. - when(repositories.findExtension("ext", "ns")).thenReturn(null); + .andExpect(jsonPath("$.flags[0].state").value("PUBLISHED")); - mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.flags[0].state").value("REJECTED")) - .andExpect(jsonPath("$.flags[0].activeVersionCount").value(0)); + verify(admins).getNameSquattingFlags( + eq("publisher"), + eq("ns"), + eq("ext"), + eq(List.of("PUBLISHED", "DEACTIVATED")), + eq("2026-01-01T00:00Z"), + eq("2026-02-01T00:00Z"), + eq(25), + eq(50), + eq("asc")); } @Test - void getFlaggedExtensions_reports_deactivated_when_no_active_versions_remain() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - stubFlaggedExtension("ns", "ext"); - - var extension = TestData.extension("ns", "ext", false); - when(repositories.findExtension("ext", "ns")).thenReturn(extension); - when(repositories.findActiveVersions(extension)).thenReturn(Streamable.empty()); + void getFlaggedExtensions_applies_the_paging_defaults() throws Exception { + when(admins.checkAdminUser()).thenReturn(adminUser()); + when( + admins.getNameSquattingFlags( + any(), + any(), + any(), + any(), + any(), + any(), + any(Integer.class), + any(Integer.class), + any())) + .thenReturn(flagList()); mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.flags[0].state").value("DEACTIVATED")) - .andExpect(jsonPath("$.flags[0].activeVersionCount").value(0)); + .andExpect(status().isOk()); + + verify(admins).getNameSquattingFlags( + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + eq(10), + eq(0), + eq("desc")); } @Test void getFlaggedExtensions_validates_paging_parameters() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(adminUser()); mockMvc.perform( get("/admin/name-squatting") @@ -169,8 +173,20 @@ void getFlaggedExtensions_validates_paging_parameters() throws Exception { } @Test - void getFlaggedExtensions_rejects_unknown_state_filter() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + void getFlaggedExtensions_reports_a_rejected_query() throws Exception { + when(admins.checkAdminUser()).thenReturn(adminUser()); + when( + admins.getNameSquattingFlags( + any(), + any(), + any(), + any(), + any(), + any(), + any(Integer.class), + any(Integer.class), + any())) + .thenThrow(new ErrorResultException("Unknown state filter: SOMETHING", HttpStatus.BAD_REQUEST)); mockMvc.perform( get("/admin/name-squatting") @@ -181,110 +197,53 @@ void getFlaggedExtensions_rejects_unknown_state_filter() throws Exception { } @Test - void getFlaggedExtensions_rejects_unknown_sort_order() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + void getCounts_returns_the_breakdown_per_state() throws Exception { + when(admins.checkAdminUser()).thenReturn(adminUser()); + + var counts = new NameSquattingCountsJson(); + counts.setTotal(9); + counts.setPublished(4); + counts.setDeactivated(2); + counts.setRejected(3); + when(admins.getNameSquattingCounts(any(), any(), any(), any(), any())).thenReturn(counts); mockMvc.perform( - get("/admin/name-squatting") - .param("sortOrder", "sideways") + get("/admin/name-squatting/counts") + .param("publisher", "publisher") .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("Unsupported sortOrder value: sideways")); - } - - @Test - void getCounts_breaks_down_flagged_extensions_by_state() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - - when(repositories.countFlaggedExtensions(eq(CHECK_TYPE), any(), any(), any(), any(), any(), eq(null))) - .thenReturn(9L); - when( - repositories.countFlaggedExtensions( - eq(CHECK_TYPE), - any(), - any(), - any(), - any(), - any(), - eq(new NameSquattingAPI.ExtensionStateFilter(true, false, false)))) - .thenReturn(4L); - when( - repositories.countFlaggedExtensions( - eq(CHECK_TYPE), - any(), - any(), - any(), - any(), - any(), - eq(new NameSquattingAPI.ExtensionStateFilter(false, true, false)))) - .thenReturn(2L); - when( - repositories.countFlaggedExtensions( - eq(CHECK_TYPE), - any(), - any(), - any(), - any(), - any(), - eq(new NameSquattingAPI.ExtensionStateFilter(false, false, true)))) - .thenReturn(3L); - - mockMvc.perform(get("/admin/name-squatting/counts").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(9)) .andExpect(jsonPath("$.published").value(4)) .andExpect(jsonPath("$.deactivated").value(2)) .andExpect(jsonPath("$.rejected").value(3)); + + verify(admins).getNameSquattingCounts(eq("publisher"), isNull(), isNull(), isNull(), isNull()); } @Test - void clearFindings_removes_the_records_and_logs_the_action() throws Exception { - var adminUser = TestData.adminUser(); + void clearFindings_hands_the_request_to_the_service() throws Exception { + var adminUser = adminUser(); when(admins.checkAdminUser()).thenReturn(adminUser); - when(repositories.deleteValidationFailures(CHECK_TYPE, "ns", "ext")).thenReturn(2); + when(admins.clearNameSquattingFindings(eq(adminUser), any())).thenReturn(actionResponse("cleared")); mockMvc.perform( post("/admin/name-squatting/clear") .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .content(TARGETS) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.processed").value(1)) .andExpect(jsonPath("$.successful").value(1)) .andExpect(jsonPath("$.failed").value(0)) .andExpect(jsonPath("$.results[0].success").value(true)) - .andExpect( - jsonPath("$.results[0].message") - .value("Cleared 2 name squatting findings for extension ns.ext as a false positive")); - - verify(repositories).deleteValidationFailures(CHECK_TYPE, "ns", "ext"); - verify(logs).logAction(eq(adminUser), any(ResultJson.class)); + .andExpect(jsonPath("$.results[0].message").value("cleared")); } @Test - void clearFindings_reports_a_failure_when_nothing_is_recorded() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - when(repositories.deleteValidationFailures(CHECK_TYPE, "ns", "ext")).thenReturn(0); - - mockMvc.perform( - post("/admin/name-squatting/clear") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.successful").value(0)) - .andExpect(jsonPath("$.failed").value(1)) - .andExpect(jsonPath("$.results[0].success").value(false)) - .andExpect( - jsonPath("$.results[0].error") - .value("No name squatting findings are recorded for this extension")); - - verify(logs, never()).logAction(any(), any()); - } - - @Test - void clearFindings_requires_at_least_one_extension() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + void clearFindings_reports_a_rejected_request() throws Exception { + when(admins.checkAdminUser()).thenReturn(adminUser()); + when(admins.clearNameSquattingFindings(any(), any())) + .thenThrow(new ErrorResultException("At least one extension is required", HttpStatus.BAD_REQUEST)); mockMvc.perform( post("/admin/name-squatting/clear") @@ -296,117 +255,20 @@ void clearFindings_requires_at_least_one_extension() throws Exception { } @Test - void clearFindings_rejects_targets_without_a_name() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - - mockMvc.perform( - post("/admin/name-squatting/clear") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\"}]}") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect( - jsonPath("$.error").value("Each extension must have a namespace and an extension name")); - } - - @Test - void deleteExtensions_deactivates_every_active_version() throws Exception { - var adminUser = TestData.adminUser(); + void deleteExtensions_hands_the_request_to_the_service() throws Exception { + var adminUser = adminUser(); when(admins.checkAdminUser()).thenReturn(adminUser); - - var extension = TestData.extension("ns", "ext", true); - when(repositories.findExtension("ext", "ns")).thenReturn(extension); - when(repositories.findActiveVersions(extension)) - .thenReturn( - Streamable.of( - TestData.version("universal", "1.0.0"), - TestData.version("linux-x64", "2.0.0"))); - when(admins.deleteExtensionNoWait(any(), anyString(), anyString(), any(TargetPlatformVersion[].class))) - .thenReturn(ResultJson.success("deleted")); + when(admins.deleteNameSquattingExtensions(eq(adminUser), any())).thenReturn(actionResponse("deactivated")); mockMvc.perform( post("/admin/name-squatting/delete") .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .content(TARGETS) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.successful").value(1)) .andExpect(jsonPath("$.results[0].success").value(true)) - .andExpect( - jsonPath("$.results[0].message") - .value("Deactivated 2 versions of extension ns.ext flagged for name squatting")); - - verify(admins).deleteExtensionNoWait( - eq(adminUser), - eq("ns"), - eq("ext"), - eq(new TargetPlatformVersion("universal", "1.0.0")), - eq(new TargetPlatformVersion("linux-x64", "2.0.0"))); - verify(logs).logAction(eq(adminUser), any(ResultJson.class)); - } - - @Test - void deleteExtensions_refuses_an_extension_that_was_never_created() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - when(repositories.findExtension("ext", "ns")).thenReturn(null); - - mockMvc.perform( - post("/admin/name-squatting/delete") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.successful").value(0)) - .andExpect(jsonPath("$.failed").value(1)) - .andExpect( - jsonPath("$.results[0].error") - .value("Extension does not exist, its publication was blocked by the check")); - - verify(admins, never()).deleteExtensionNoWait(any(), anyString(), anyString()); - } - - @Test - void deleteExtensions_refuses_an_extension_that_is_already_deactivated() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - - var extension = TestData.extension("ns", "ext", false); - when(repositories.findExtension("ext", "ns")).thenReturn(extension); - when(repositories.findActiveVersions(extension)).thenReturn(Streamable.empty()); - - mockMvc.perform( - post("/admin/name-squatting/delete") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.failed").value(1)) - .andExpect( - jsonPath("$.results[0].error") - .value("Extension has no active versions left to deactivate")); - } - - @Test - void deleteExtensions_reports_why_a_deletion_was_refused() throws Exception { - when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - - var extension = TestData.extension("ns", "ext", true); - when(repositories.findExtension("ext", "ns")).thenReturn(extension); - when(repositories.findActiveVersions(extension)) - .thenReturn(Streamable.of(TestData.version("universal", "1.0.0"))); - when(admins.deleteExtensionNoWait(any(), anyString(), anyString(), any(TargetPlatformVersion[].class))) - .thenThrow( - new ErrorResultException( - "Extension is bundled by other extensions", - HttpStatus.BAD_REQUEST)); - - mockMvc.perform( - post("/admin/name-squatting/delete") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.failed").value(1)) - .andExpect(jsonPath("$.results[0].error").value("Extension is bundled by other extensions")); + .andExpect(jsonPath("$.results[0].message").value("deactivated")); } @Test @@ -416,6 +278,17 @@ void getFlaggedExtensions_requires_admin() throws Exception { mockMvc.perform(get("/admin/name-squatting").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); + + verify(admins, never()).getNameSquattingFlags( + any(), + any(), + any(), + any(), + any(), + any(), + any(Integer.class), + any(Integer.class), + any()); } @Test @@ -425,6 +298,8 @@ void getCounts_requires_admin() throws Exception { mockMvc.perform(get("/admin/name-squatting/counts").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); + + verify(admins, never()).getNameSquattingCounts(any(), any(), any(), any(), any()); } @Test @@ -435,108 +310,46 @@ void moderation_actions_require_admin() throws Exception { mockMvc.perform( post("/admin/name-squatting/clear") .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .content(TARGETS) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); mockMvc.perform( post("/admin/name-squatting/delete") .contentType(MediaType.APPLICATION_JSON) - .content("{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}") + .content(TARGETS) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); - verify(repositories, never()).deleteValidationFailures(anyString(), anyString(), anyString()); + verify(admins, never()).clearNameSquattingFindings(any(), any()); + verify(admins, never()).deleteNameSquattingExtensions(any(), any()); } - /** - * Stub one flagged extension with two findings, newest first, as the repository returns them. - */ - private void stubFlaggedExtension(String namespace, String extensionName) { - when( - repositories.findFlaggedExtensionKeys( - eq(CHECK_TYPE), - any(), - any(), - any(), - any(), - any(), - any(), - anyBoolean(), - anyInt(), - anyInt())) - .thenReturn(List.of(namespace + "/" + extensionName)); - when(repositories.countFlaggedExtensions(eq(CHECK_TYPE), any(), any(), any(), any(), any(), any())) - .thenReturn(3L); - when(repositories.findValidationFailures(eq(CHECK_TYPE), eq(namespace), eq(extensionName), any(), any())) - .thenReturn( - List.of( - TestData.failure( - 2, - TestData.scan(20, namespace, extensionName, "2.0.0", ScanStatus.PASSED), - LAST_DETECTED, - false), - TestData.failure( - 1, - TestData.scan(10, namespace, extensionName, "1.0.0", ScanStatus.REJECTED), - FIRST_DETECTED, - true))); + private static NameSquattingFlagListJson flagList() { + var flag = new NameSquattingFlagJson(); + flag.setNamespace("ns"); + flag.setExtensionName("ext"); + flag.setState("PUBLISHED"); + + var result = new NameSquattingFlagListJson(); + result.setOffset(50); + result.setTotalSize(3); + result.setFlags(List.of(flag)); + return result; + } + + private static NameSquattingActionResponseJson actionResponse(String message) { + var response = new NameSquattingActionResponseJson(); + response.setProcessed(1); + response.setSuccessful(1); + response.setFailed(0); + response.setResults(List.of(NameSquattingActionResultJson.success("ns", "ext", message))); + return response; } - private static class TestData { - - static ExtensionScan scan(long id, String namespace, String name, String version, ScanStatus status) { - var scan = new ExtensionScan(); - scan.setId(id); - scan.setNamespaceName(namespace); - scan.setExtensionName(name); - scan.setExtensionDisplayName("Extension Display Name"); - scan.setExtensionVersion(version); - scan.setTargetPlatform("universal"); - scan.setUniversalTargetPlatform(true); - scan.setPublisher("publisher"); - scan.setPublisherUrl("https://example.com/publisher"); - scan.setStartedAt(LocalDateTime.of(2026, 1, 1, 0, 0)); - scan.setStatus(status); - return scan; - } - - static ExtensionValidationFailure failure( - long id, - ExtensionScan scan, - LocalDateTime detectedAt, - boolean enforced - ) { - var failure = ExtensionValidationFailure - .create(CHECK_TYPE, "Levenshtein Distance", "Too similar to an existing extension"); - failure.setId(id); - failure.setScan(scan); - failure.setDetectedAt(detectedAt); - failure.setEnforced(enforced); - return failure; - } - - static Extension extension(String namespaceName, String name, boolean active) { - var namespace = new Namespace(); - namespace.setName(namespaceName); - var extension = new Extension(); - extension.setName(name); - extension.setNamespace(namespace); - extension.setActive(active); - return extension; - } - - static ExtensionVersion version(String targetPlatform, String version) { - var extVersion = new ExtensionVersion(); - extVersion.setTargetPlatform(targetPlatform); - extVersion.setVersion(version); - return extVersion; - } - - static UserData adminUser() { - var user = new UserData(); - user.setRole(UserData.Role.ADMIN); - return user; - } + private static UserData adminUser() { + var user = new UserData(); + user.setRole(UserData.Role.ADMIN); + return user; } } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAdminServiceTest.java b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAdminServiceTest.java new file mode 100644 index 000000000..1174d4c85 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAdminServiceTest.java @@ -0,0 +1,461 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.admin; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.util.Streamable; +import org.springframework.http.HttpStatus; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.NameSquattingActionRequest; +import org.eclipse.openvsx.json.NameSquattingTargetJson; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.TargetPlatformVersion; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the name squatting moderation logic in {@link AdminService}: grouping the recorded + * findings per extension, counting them per state, and the two moderation actions. + */ +@ExtendWith(MockitoExtension.class) +class NameSquattingAdminServiceTest { + + private static final String CHECK_TYPE = "NAME_SQUATTING"; + + private static final LocalDateTime FIRST_DETECTED = LocalDateTime.of(2026, 1, 5, 9, 30); + + private static final LocalDateTime LAST_DETECTED = LocalDateTime.of(2026, 2, 11, 14, 0); + + @Mock + RepositoryService repositories; + + @Mock + ExtensionService extensions; + + @Mock + LogService logs; + + @InjectMocks + AdminService admins; + + private final UserData adminUser = TestData.adminUser(); + + @Test + void getNameSquattingFlags_groups_findings_per_extension() { + stubFlaggedExtension("ns", "ext"); + + var extension = TestData.extension("ns", "ext", true); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)) + .thenReturn(Streamable.of(TestData.version("universal", "1.0.0"))); + + var result = admins.getNameSquattingFlags(null, null, null, null, null, null, 10, 0, "desc"); + + assertThat(result.getTotalSize()).isEqualTo(3); + assertThat(result.getOffset()).isZero(); + assertThat(result.getFlags()).hasSize(1); + + var flag = result.getFlags().getFirst(); + assertThat(flag.getNamespace()).isEqualTo("ns"); + assertThat(flag.getExtensionName()).isEqualTo("ext"); + assertThat(flag.getDisplayName()).isEqualTo("Extension Display Name"); + assertThat(flag.getPublisher()).isEqualTo("publisher"); + assertThat(flag.getState()).isEqualTo("PUBLISHED"); + assertThat(flag.getActiveVersionCount()).isEqualTo(1); + assertThat(flag.getFindingCount()).isEqualTo(2); + assertThat(flag.getFindings()).hasSize(2); + // Findings come back newest first, so the dates bracket the whole history. + assertThat(flag.getFindings().getFirst().getVersion()).isEqualTo("2.0.0"); + assertThat(flag.getFindings().getFirst().getScanStatus()).isEqualTo("PASSED"); + assertThat(flag.getFindings().getFirst().isEnforcedFlag()).isFalse(); + assertThat(flag.getFindings().getLast().getVersion()).isEqualTo("1.0.0"); + assertThat(flag.getDateLastDetected()).isEqualTo("2026-02-11T14:00Z"); + assertThat(flag.getDateFirstDetected()).isEqualTo("2026-01-05T09:30Z"); + } + + @Test + void getNameSquattingFlags_reports_rejected_when_extension_was_never_created() { + stubFlaggedExtension("ns", "ext"); + + // Publication was blocked by an enforced check, so no extension exists. + when(repositories.findExtension("ext", "ns")).thenReturn(null); + + var flag = admins + .getNameSquattingFlags(null, null, null, null, null, null, 10, 0, "desc") + .getFlags() + .getFirst(); + + assertThat(flag.getState()).isEqualTo("REJECTED"); + assertThat(flag.getActiveVersionCount()).isZero(); + } + + @Test + void getNameSquattingFlags_reports_deactivated_when_no_active_versions_remain() { + stubFlaggedExtension("ns", "ext"); + + var extension = TestData.extension("ns", "ext", false); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)).thenReturn(Streamable.empty()); + + var flag = admins + .getNameSquattingFlags(null, null, null, null, null, null, 10, 0, "desc") + .getFlags() + .getFirst(); + + assertThat(flag.getState()).isEqualTo("DEACTIVATED"); + assertThat(flag.getActiveVersionCount()).isZero(); + } + + @Test + void getNameSquattingFlags_rejects_unknown_state_filter() { + assertThatThrownBy( + () -> admins.getNameSquattingFlags( + null, + null, + null, + List.of("PUBLISHED,SOMETHING"), + null, + null, + 10, + 0, + "desc")) + .isInstanceOf(ErrorResultException.class) + .hasMessage("Unknown state filter: SOMETHING"); + } + + @Test + void getNameSquattingFlags_rejects_unknown_sort_order() { + assertThatThrownBy( + () -> admins.getNameSquattingFlags(null, null, null, null, null, null, 10, 0, "sideways")) + .isInstanceOf(ErrorResultException.class) + .hasMessage("Unsupported sortOrder value: sideways"); + } + + @Test + void getNameSquattingFlags_rejects_an_unparseable_detection_date() { + assertThatThrownBy( + () -> admins.getNameSquattingFlags(null, null, null, null, "yesterday", null, 10, 0, "desc")) + .isInstanceOf(ErrorResultException.class) + .hasMessage("Invalid ISO date-time for parameter 'dateDetectedFrom': yesterday"); + } + + @Test + void getNameSquattingCounts_breaks_down_flagged_extensions_by_state() { + when(repositories.countFlaggedExtensions(eq(CHECK_TYPE), any(), any(), any(), any(), any(), eq(null))) + .thenReturn(9L); + when( + repositories.countFlaggedExtensions( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + eq(new AdminService.ExtensionStateFilter(true, false, false)))) + .thenReturn(4L); + when( + repositories.countFlaggedExtensions( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + eq(new AdminService.ExtensionStateFilter(false, true, false)))) + .thenReturn(2L); + when( + repositories.countFlaggedExtensions( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + eq(new AdminService.ExtensionStateFilter(false, false, true)))) + .thenReturn(3L); + + var counts = admins.getNameSquattingCounts(null, null, null, null, null); + + assertThat(counts.getTotal()).isEqualTo(9); + assertThat(counts.getPublished()).isEqualTo(4); + assertThat(counts.getDeactivated()).isEqualTo(2); + assertThat(counts.getRejected()).isEqualTo(3); + } + + @Test + void clearNameSquattingFindings_removes_the_records_and_logs_the_action() { + when(repositories.deleteValidationFailures(CHECK_TYPE, "ns", "ext")).thenReturn(2); + + var response = admins.clearNameSquattingFindings(adminUser, request("ns", "ext")); + + assertThat(response.getProcessed()).isEqualTo(1); + assertThat(response.getSuccessful()).isEqualTo(1); + assertThat(response.getFailed()).isZero(); + assertThat(response.getResults().getFirst().isSuccess()).isTrue(); + assertThat(response.getResults().getFirst().getMessage()) + .isEqualTo("Cleared 2 name squatting findings for extension ns.ext as a false positive"); + + verify(repositories).deleteValidationFailures(CHECK_TYPE, "ns", "ext"); + verify(logs).logAction(eq(adminUser), any(ResultJson.class)); + } + + @Test + void clearNameSquattingFindings_reports_a_failure_when_nothing_is_recorded() { + when(repositories.deleteValidationFailures(CHECK_TYPE, "ns", "ext")).thenReturn(0); + + var response = admins.clearNameSquattingFindings(adminUser, request("ns", "ext")); + + assertThat(response.getSuccessful()).isZero(); + assertThat(response.getFailed()).isEqualTo(1); + assertThat(response.getResults().getFirst().isSuccess()).isFalse(); + assertThat(response.getResults().getFirst().getError()) + .isEqualTo("No name squatting findings are recorded for this extension"); + + verify(logs, never()).logAction(any(), any()); + } + + @Test + void clearNameSquattingFindings_requires_at_least_one_extension() { + var request = new NameSquattingActionRequest(); + request.setTargets(List.of()); + + assertThatThrownBy(() -> admins.clearNameSquattingFindings(adminUser, request)) + .isInstanceOf(ErrorResultException.class) + .hasMessage("At least one extension is required") + .extracting(exc -> ((ErrorResultException) exc).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void clearNameSquattingFindings_rejects_targets_without_a_name() { + var request = request("ns", null); + + assertThatThrownBy(() -> admins.clearNameSquattingFindings(adminUser, request)) + .isInstanceOf(ErrorResultException.class) + .hasMessage("Each extension must have a namespace and an extension name"); + } + + @Test + void deleteNameSquattingExtensions_deactivates_every_active_version() { + var extension = TestData.extension("ns", "ext", true); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)) + .thenReturn( + Streamable.of( + TestData.version("universal", "1.0.0"), + TestData.version("linux-x64", "2.0.0"))); + when( + extensions.deleteExtension( + any(), + anyBoolean(), + anyString(), + anyString(), + any(TargetPlatformVersion[].class))) + .thenReturn(ResultJson.success("deleted")); + + var response = admins.deleteNameSquattingExtensions(adminUser, request("ns", "ext")); + + assertThat(response.getSuccessful()).isEqualTo(1); + assertThat(response.getResults().getFirst().isSuccess()).isTrue(); + assertThat(response.getResults().getFirst().getMessage()) + .isEqualTo("Deactivated 2 versions of extension ns.ext flagged for name squatting"); + + verify(extensions).deleteExtension( + eq(adminUser), + eq(false), + eq("ns"), + eq("ext"), + eq(new TargetPlatformVersion("universal", "1.0.0")), + eq(new TargetPlatformVersion("linux-x64", "2.0.0"))); + verify(logs).logAction(eq(adminUser), any(ResultJson.class)); + } + + @Test + void deleteNameSquattingExtensions_refuses_an_extension_that_was_never_created() { + when(repositories.findExtension("ext", "ns")).thenReturn(null); + + var response = admins.deleteNameSquattingExtensions(adminUser, request("ns", "ext")); + + assertThat(response.getSuccessful()).isZero(); + assertThat(response.getFailed()).isEqualTo(1); + assertThat(response.getResults().getFirst().getError()) + .isEqualTo("Extension does not exist, its publication was blocked by the check"); + + verify(extensions, never()).deleteExtension(any(), anyBoolean(), anyString(), anyString()); + } + + @Test + void deleteNameSquattingExtensions_refuses_an_extension_that_is_already_deactivated() { + var extension = TestData.extension("ns", "ext", false); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)).thenReturn(Streamable.empty()); + + var response = admins.deleteNameSquattingExtensions(adminUser, request("ns", "ext")); + + assertThat(response.getFailed()).isEqualTo(1); + assertThat(response.getResults().getFirst().getError()) + .isEqualTo("Extension has no active versions left to deactivate"); + } + + @Test + void deleteNameSquattingExtensions_reports_why_a_deletion_was_refused() { + var extension = TestData.extension("ns", "ext", true); + when(repositories.findExtension("ext", "ns")).thenReturn(extension); + when(repositories.findActiveVersions(extension)) + .thenReturn(Streamable.of(TestData.version("universal", "1.0.0"))); + when( + extensions.deleteExtension( + any(), + anyBoolean(), + anyString(), + anyString(), + any(TargetPlatformVersion[].class))) + .thenThrow( + new ErrorResultException( + "Extension is bundled by other extensions", + HttpStatus.BAD_REQUEST)); + + var response = admins.deleteNameSquattingExtensions(adminUser, request("ns", "ext")); + + assertThat(response.getFailed()).isEqualTo(1); + assertThat(response.getResults().getFirst().getError()) + .isEqualTo("Extension is bundled by other extensions"); + } + + private static NameSquattingActionRequest request(String namespace, String extensionName) { + var target = new NameSquattingTargetJson(); + target.setNamespace(namespace); + target.setExtension(extensionName); + var request = new NameSquattingActionRequest(); + request.setTargets(List.of(target)); + return request; + } + + /** + * Stub one flagged extension with two findings, newest first, as the repository returns them. + */ + private void stubFlaggedExtension(String namespace, String extensionName) { + when( + repositories.findFlaggedExtensionKeys( + eq(CHECK_TYPE), + any(), + any(), + any(), + any(), + any(), + any(), + anyBoolean(), + anyInt(), + anyInt())) + .thenReturn(List.of(namespace + "/" + extensionName)); + when(repositories.countFlaggedExtensions(eq(CHECK_TYPE), any(), any(), any(), any(), any(), any())) + .thenReturn(3L); + when(repositories.findValidationFailures(eq(CHECK_TYPE), eq(namespace), eq(extensionName), any(), any())) + .thenReturn( + List.of( + TestData.failure( + 2, + TestData.scan(20, namespace, extensionName, "2.0.0", ScanStatus.PASSED), + LAST_DETECTED, + false), + TestData.failure( + 1, + TestData.scan(10, namespace, extensionName, "1.0.0", ScanStatus.REJECTED), + FIRST_DETECTED, + true))); + } + + private static class TestData { + + static ExtensionScan scan(long id, String namespace, String name, String version, ScanStatus status) { + var scan = new ExtensionScan(); + scan.setId(id); + scan.setNamespaceName(namespace); + scan.setExtensionName(name); + scan.setExtensionDisplayName("Extension Display Name"); + scan.setExtensionVersion(version); + scan.setTargetPlatform("universal"); + scan.setUniversalTargetPlatform(true); + scan.setPublisher("publisher"); + scan.setPublisherUrl("https://example.com/publisher"); + scan.setStartedAt(LocalDateTime.of(2026, 1, 1, 0, 0)); + scan.setStatus(status); + return scan; + } + + static ExtensionValidationFailure failure( + long id, + ExtensionScan scan, + LocalDateTime detectedAt, + boolean enforced + ) { + var failure = ExtensionValidationFailure + .create(CHECK_TYPE, "Levenshtein Distance", "Too similar to an existing extension"); + failure.setId(id); + failure.setScan(scan); + failure.setDetectedAt(detectedAt); + failure.setEnforced(enforced); + return failure; + } + + static Extension extension(String namespaceName, String name, boolean active) { + var namespace = new Namespace(); + namespace.setName(namespaceName); + var extension = new Extension(); + extension.setName(name); + extension.setNamespace(namespace); + extension.setActive(active); + return extension; + } + + static ExtensionVersion version(String targetPlatform, String version) { + var extVersion = new ExtensionVersion(); + extVersion.setTargetPlatform(targetPlatform); + extVersion.setVersion(version); + return extVersion; + } + + static UserData adminUser() { + var user = new UserData(); + user.setRole(UserData.Role.ADMIN); + return user; + } + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 3deb27ee2..9892f5049 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -27,7 +27,7 @@ import org.springframework.data.domain.Pageable; import org.eclipse.openvsx.AbstractPostgresContainerTest; -import org.eclipse.openvsx.admin.NameSquattingAPI; +import org.eclipse.openvsx.admin.AdminService; import org.eclipse.openvsx.entities.AdminScanDecision; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.DailyUsageStats; @@ -401,7 +401,7 @@ void testExecuteQueries() { extension.getName(), NOW, NOW, - new NameSquattingAPI.ExtensionStateFilter(true, true, true), + new AdminService.ExtensionStateFilter(true, true, true), false, 10, 0), @@ -412,7 +412,7 @@ void testExecuteQueries() { extension.getName(), NOW, NOW, - new NameSquattingAPI.ExtensionStateFilter(true, true, true)), + new AdminService.ExtensionStateFilter(true, true, true)), () -> repositories.findValidationFailures( validationFailure.getCheckType(), namespace.getName(),