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 new file mode 100644 index 000000000..18a1df4f3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/admin/NameSquattingAPI.java @@ -0,0 +1,296 @@ +/******************************************************************************** + * 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.util.List; + +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; +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; + +/** + * 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 { + + public static final String NAME_SQUATTING_CHECK_TYPE = SimilarityCheckService.CHECK_TYPE; + + /** The flagged extension is live: it exists and has at least one active version. */ + public static final String NAME_SQUATTING_STATE_PUBLISHED = "PUBLISHED"; + + /** The flagged extension exists but all of its versions have been deactivated. */ + public static final String NAME_SQUATTING_STATE_DEACTIVATED = "DEACTIVATED"; + + /** 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; + + public NameSquattingAPI(AdminService admins) { + this.admins = admins; + } + + /** + * 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 result = admins.getNameSquattingFlags( + publisher, + namespace, + name, + state, + dateDetectedFrom, + dateDetectedTo, + size, + offset, + sortOrder); + 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 counts = admins + .getNameSquattingCounts(publisher, namespace, name, dateDetectedFrom, dateDetectedTo); + 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(); + return ResponseEntity.ok(admins.clearNameSquattingFindings(adminUser, request)); + } 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(); + return ResponseEntity.ok(admins.deleteNameSquattingExtensions(adminUser, request)); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(NameSquattingActionResponseJson.class); + } + } +} 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/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/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..13d0a471c 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; @@ -1026,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, @@ -1401,6 +1406,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.AdminService.@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.AdminService.@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/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/admin/NameSquattingAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java new file mode 100644 index 000000000..e9af4266e --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/admin/NameSquattingAPITest.java @@ -0,0 +1,355 @@ +/******************************************************************************** + * 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.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.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.UserData; +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 static org.mockito.ArgumentMatchers.any; +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; +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; + +/** + * 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 } +) +@AutoConfigureMockMvc(addFilters = false) +class NameSquattingAPITest { + + private static final String TARGETS = "{\"targets\":[{\"namespace\":\"ns\",\"extension\":\"ext\"}]}"; + + @Autowired + MockMvc mockMvc; + + @MockitoBean + AdminService admins; + + @MockitoBean + MeterRegistry meterRegistry; + + @Test + 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") + .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(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].state").value("PUBLISHED")); + + 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_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()); + + 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(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_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") + .param("state", "PUBLISHED,SOMETHING") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Unknown state filter: SOMETHING")); + } + + @Test + 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/counts") + .param("publisher", "publisher") + .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_hands_the_request_to_the_service() throws Exception { + var adminUser = adminUser(); + when(admins.checkAdminUser()).thenReturn(adminUser); + when(admins.clearNameSquattingFindings(eq(adminUser), any())).thenReturn(actionResponse("cleared")); + + mockMvc.perform( + post("/admin/name-squatting/clear") + .contentType(MediaType.APPLICATION_JSON) + .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")); + } + + @Test + 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") + .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 deleteExtensions_hands_the_request_to_the_service() throws Exception { + var adminUser = adminUser(); + when(admins.checkAdminUser()).thenReturn(adminUser); + when(admins.deleteNameSquattingExtensions(eq(adminUser), any())).thenReturn(actionResponse("deactivated")); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .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")); + } + + @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()); + + verify(admins, never()).getNameSquattingFlags( + any(), + any(), + any(), + any(), + any(), + any(), + any(Integer.class), + any(Integer.class), + any()); + } + + @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()); + + verify(admins, never()).getNameSquattingCounts(any(), any(), any(), any(), any()); + } + + @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) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + mockMvc.perform( + post("/admin/name-squatting/delete") + .contentType(MediaType.APPLICATION_JSON) + .content(TARGETS) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + verify(admins, never()).clearNameSquattingFindings(any(), any()); + verify(admins, never()).deleteNameSquattingExtensions(any(), any()); + } + + 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 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/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 e8bd4e7b3..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,6 +27,7 @@ import org.springframework.data.domain.Pageable; import org.eclipse.openvsx.AbstractPostgresContainerTest; +import org.eclipse.openvsx.admin.AdminService; import org.eclipse.openvsx.entities.AdminScanDecision; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.DailyUsageStats; @@ -362,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", @@ -392,8 +394,39 @@ void testExecuteQueries() { () -> repositories.findValidationFailure(validationFailure.getId()), () -> repositories.findDistinctValidationFailureRuleNames(), () -> repositories.findDistinctValidationFailureCheckTypes(), + () -> repositories.findFlaggedExtensionKeys( + validationFailure.getCheckType(), + namespace.getName(), + scan.getPublisher(), + extension.getName(), + NOW, + NOW, + new AdminService.ExtensionStateFilter(true, true, true), + false, + 10, + 0), + () -> repositories.countFlaggedExtensions( + validationFailure.getCheckType(), + namespace.getName(), + scan.getPublisher(), + extension.getName(), + NOW, + NOW, + new AdminService.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 }; +}