diff --git a/.gitignore b/.gitignore
index 298836542..39b97e884 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.idea/
.project/
.settings/
+.claude/
data/
diff --git a/server/build.gradle b/server/build.gradle
index cf47a6c72..8c150dff8 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -265,18 +265,16 @@ test {
}
tasks.register('unitTests', Test) {
- description = 'Runs unit tests (excluding integration tests).'
+ description = 'Runs unit tests (excluding container-backed integration tests).'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
- useJUnitPlatform()
- exclude 'org/eclipse/openvsx/ExtensionDeleteTest.class'
- exclude 'org/eclipse/openvsx/IntegrationTest.class'
- exclude 'org/eclipse/openvsx/cache/CacheServiceTest.class'
- exclude 'org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.class'
- exclude 'org/eclipse/openvsx/repositories/NamespaceRepositoryTest.class'
- exclude 'org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.class'
- exclude 'org/eclipse/openvsx/storage/AwsStorageServiceIntegrationTest.class'
+ // Exclude container-backed tests by tag rather than a hand-maintained class list: any test that
+ // extends AbstractPostgresContainerTest (or is otherwise tagged "integration") is skipped here, so
+ // this fast task never spins up a Docker container. New integration tests are covered automatically.
+ useJUnitPlatform {
+ excludeTags 'integration'
+ }
}
tasks.register('s3IntegrationTests', Test) {
@@ -284,8 +282,11 @@ tasks.register('s3IntegrationTests', Test) {
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
- useJUnitPlatform()
- include 'org/eclipse/openvsx/storage/AwsStorageServiceIntegrationTest.class'
+ // Select S3 integration tests by tag rather than a hardcoded class name, so new tests tagged "s3"
+ // are picked up automatically.
+ useJUnitPlatform {
+ includeTags 's3'
+ }
// Set system properties for test configuration
systemProperty 'spring.profiles.active', 's3-integration'
diff --git a/server/config/eclipse-java-formatter.xml b/server/config/eclipse-java-formatter.xml
index 67c1f0741..47a759610 100644
--- a/server/config/eclipse-java-formatter.xml
+++ b/server/config/eclipse-java-formatter.xml
@@ -84,5 +84,9 @@
this (49); parameters default to "never split" (0), so only that one needs overriding. -->
* The extension will be locked for the operation. If the lock can not be acquired, i.e. the extension * is updated at the same time, the operation will fail. *
- * If the resolved extension version has not been published by the given user, - * a {@code ErrorResultException} will be thrown. + * If {@code restrictedToUser} is {@code true}, the deletion operation is only successful if the user + * has published the respective extension version. + *
+ * The versions to delete must be named explicitly; an empty {@code targetVersions} deletes nothing. */ @Transactional(rollbackOn = ErrorResultException.class) - public ResultJson deleteUserExtension( + public ResultJson deleteExtension( UserData user, + boolean restrictedToUser, String namespaceName, String extensionName, TargetPlatformVersion... targetVersions ) throws ErrorResultException { - return deleteExtension(user, true, namespaceName, extensionName, targetVersions); + var extension = lockExtensionNoWait(namespaceName, extensionName); + var uniqueVersions = distinctVersions(targetVersions); + var versions = resolveVersions(user, restrictedToUser, namespaceName, extensionName, uniqueVersions); + + // if all active versions of the extension would get deactivated, check for dependencies + if (extension.isActive() && + repositories.isDeleteAllActiveVersions(namespaceName, extensionName, uniqueVersions)) { + checkNoDependencies(extension); + } + + return deleteExtensionVersions(user, versions); } /** - * Deletes the given extension. + * Purges (permanently deletes) the given extension versions from the database and storage. *
- * The extension will be locked for the operation. If the lock can not be acquired, i.e. the extension - * is updated at the same time, the operation will fail. + * Unlike {@link #deleteExtension(UserData, boolean, String, String, TargetPlatformVersion...)}, which + * soft-deletes versions (keeping the row so the version identity stays reserved), this physically removes + * the rows and frees the version identity for republishing. It is intended for administrative purge and + * automated cleanup (mirror, extension control) and performs no user-ownership check. *
- * If {@code restrictedToUser} is {@code true}, the deletion operation is only successful if the user - * has published the respective extension version. + * The versions to purge must be named explicitly; an empty {@code targetVersions} purges nothing. Use + * {@link #purgeExtension(UserData, Extension, boolean)} to purge an extension as a whole. + *
+ * The method will try to lock the extension and fail with an {@code ErrorResultException} if it can't acquire it.
*/
@Transactional(rollbackOn = ErrorResultException.class)
- public ResultJson deleteExtension(
+ public ResultJson purgeExtensionNoWait(
UserData user,
- boolean restrictedToUser,
String namespaceName,
String extensionName,
TargetPlatformVersion... targetVersions
) throws ErrorResultException {
var extension = lockExtensionNoWait(namespaceName, extensionName);
- if (repositories
- .isDeleteAllVersions(restrictedToUser ? user : null, namespaceName, extensionName, targetVersions)) {
- return deleteExtension(user, extension, true);
+ var uniqueVersions = distinctVersions(targetVersions);
+ var versions = resolveVersions(user, false, namespaceName, extensionName, uniqueVersions);
+
+ // if every version of the extension is purged, purge the extension as a whole so that its
+ // record, reviews and search entry are removed too and nothing is left orphaned
+ if (!versions.isEmpty() && versions.size() == repositories.countVersions(namespaceName, extensionName)) {
+ return purgeExtension(user, extension, extension.isActive());
+ }
+
+ // if all active versions of the extension would get deactivated, check for dependencies
+ if (extension.isActive() &&
+ repositories.isDeleteAllActiveVersions(namespaceName, extensionName, uniqueVersions)) {
+ checkNoDependencies(extension);
+ }
+
+ return purgeExtensionVersions(user, versions);
+ }
+
+ /**
+ * Returns the given target versions with duplicates removed, so that callers can reason about the
+ * exact set of versions to delete/purge without a repeated entry inflating any count-based check.
+ */
+ private static TargetPlatformVersion[] distinctVersions(TargetPlatformVersion... targetVersions) {
+ return Arrays.stream(targetVersions).distinct().toArray(TargetPlatformVersion[]::new);
+ }
+
+ private List
+ * This physically removes the extension and all its versions from the database and storage, freeing
+ * the extension and version identities for republishing. Intended for administrative purge and automated
+ * cleanup (mirror, extension control).
*
* If {@code checkDependencies} is {@code true} and this extension is referenced by a bundle or used
- * as a dependency, the delete operation will fail.
+ * as a dependency, the operation will fail.
*
* @param user the user that will be used for logging the operation
- * @param extension the extension to delete
- * @param checkDependencies whether to check if this extension is still references by bundles or as a dependency
+ * @param extension the extension to purge
+ * @param checkDependencies whether to check if this extension is still referenced by bundles or as a dependency
*/
@Transactional(rollbackOn = ErrorResultException.class)
- public ResultJson deleteExtension(UserData user, Extension extension, boolean checkDependencies)
+ public ResultJson purgeExtension(UserData user, Extension extension, boolean checkDependencies)
throws ErrorResultException {
if (checkDependencies) {
- var bundledRefs = repositories.findBundledExtensionsReference(extension);
- if (!bundledRefs.isEmpty()) {
- throw new ErrorResultException(
- "Extension " + NamingUtil.toExtensionId(extension)
- + " is bundled by the following extension packs: "
- + bundledRefs.stream()
- .map(NamingUtil::toFileFormat)
- .collect(Collectors.joining(", ")));
- }
- var dependRefs = repositories.findDependenciesReference(extension);
- if (!dependRefs.isEmpty()) {
- throw new ErrorResultException(
- "The following extensions have a dependency on " + NamingUtil.toExtensionId(extension) + ": "
- + dependRefs.stream()
- .map(NamingUtil::toFileFormat)
- .collect(Collectors.joining(", ")));
- }
+ checkNoDependencies(extension);
}
for (var extVersion : repositories.findVersions(extension)) {
@@ -457,31 +550,50 @@ public ResultJson deleteExtension(UserData user, Extension extension, boolean ch
search.removeSearchEntry(extension);
- var result = ResultJson.success("Deleted " + NamingUtil.toExtensionId(extension));
+ var result = ResultJson.success("Purged " + NamingUtil.toExtensionId(extension));
logs.logAction(user, result);
return result;
}
@Transactional(rollbackOn = ErrorResultException.class)
- public ResultJson deleteExtensionVersion(UserData user, ExtensionVersion extVersion) {
+ public ResultJson purgeExtensionVersion(UserData user, ExtensionVersion extVersion) {
var extension = extVersion.getExtension();
removeExtensionVersion(extVersion);
extension.getVersions().remove(extVersion);
updateExtension(extension);
- var result = ResultJson.success("Deleted " + NamingUtil.toLogFormat(extVersion));
+ var result = ResultJson.success("Purged " + NamingUtil.toLogFormat(extVersion));
logs.logAction(user, result);
return result;
}
- @Transactional(rollbackOn = ErrorResultException.class)
- public void removeExtensionVersion(ExtensionVersion extVersion) {
+ private void checkNoDependencies(Extension extension) throws ErrorResultException {
+ var dependRefs = repositories.findDependenciesReference(extension);
+ if (!dependRefs.isEmpty()) {
+ throw new ErrorResultException(
+ "The following extensions have a dependency on " + NamingUtil.toExtensionId(extension) + ": "
+ + dependRefs.stream()
+ .map(NamingUtil::toFileFormat)
+ .collect(Collectors.joining(", ")));
+ }
+ }
+
+ private void deleteFiles(ExtensionVersion extVersion) {
// Clean up any pending scan jobs for this extension version
// to prevent "file not found" errors after deletion
scanPersistenceService.deleteScansForExtensionVersion(extVersion.getId());
repositories.findFiles(extVersion).map(RemoveFileJobRequest::new).forEach(scheduler::enqueue);
repositories.deleteFiles(extVersion);
+ }
+
+ /**
+ * Physically remove an extension version: strip its files from storage and delete the row.
+ * This is the low-level hard-delete primitive used by the purge paths.
+ */
+ @Transactional(rollbackOn = ErrorResultException.class)
+ public void removeExtensionVersion(ExtensionVersion extVersion) {
+ deleteFiles(extVersion);
entityManager.remove(extVersion);
}
}
diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
index 29299cc73..c7918fea4 100644
--- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
+++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
@@ -119,6 +119,15 @@ public LocalRegistryService(
@Override
public NamespaceJson getNamespace(String namespaceName) {
+ return getNamespace(namespaceName, false);
+ }
+
+ /**
+ * Build the namespace JSON. When {@code includeInactive} is {@code true}, all extensions of the
+ * namespace are listed, including inactive/soft-deleted ones; otherwise only active extensions are
+ * listed. Inactive extensions must only be exposed on admin surfaces.
+ */
+ public NamespaceJson getNamespace(String namespaceName, boolean includeInactive) {
var namespace = repositories.findNamespace(namespaceName);
if (namespace == null) {
throw new NotFoundException();
@@ -127,13 +136,15 @@ public NamespaceJson getNamespace(String namespaceName) {
json.setName(namespace.getName());
var extensionsMap = new LinkedHashMap
+ * Only extensions the user published and whose namespace the user is currently
+ * a member of are returned. Extensions the user published in a namespace they have since left
+ * (or been removed from) are excluded, since the user no longer has any access to them (see
+ * {@link #getOwnExtension}). The list includes inactive and removed (soft-deleted) versions of
+ * the extensions that do qualify.
+ *
+ * @return {@code 200 OK} with the list of extensions, or {@code 403 Forbidden} if not logged in
+ */
@GetMapping(
path = "/user/extensions",
produces = MediaType.APPLICATION_JSON_VALUE
@@ -246,7 +259,14 @@ public List
* This shows users the current state of their extension in simple terms:
- * - "published" - Extension is active and publicly available
- * - "under_review" - Extension is being reviewed (validation, scanning, etc.)
- * - "rejected" - Extension was blocked (quarantined or rejected)
+ *
+ * Access is restricted to current namespace members: a member (owner or not) sees
+ * all versions of the extension, including versions they did not publish themselves and
+ * removed (soft-deleted) ones. A user who is not a member of the namespace has no access and
+ * receives {@code 404 Not Found}, even for versions they published while they were still a member.
+ *
+ * Each returned version carries a {@code canDelete} flag mirroring the authorization enforced by
+ * {@link #deleteExtension}: owners may delete any version, other members only the versions they
+ * published themselves. This lets the settings UI disable delete controls the caller is not
+ * allowed to use.
+ *
+ * @param namespaceName the namespace of the extension
+ * @param extensionName the extension name
+ * @return {@code 200 OK} with the extension, {@code 403 Forbidden} if not logged in, or
+ * {@code 404 Not Found} if the caller is not a namespace member or the extension does
+ * not exist
+ */
@GetMapping(
path = "/user/extension/{namespaceName}/{extensionName}",
produces = MediaType.APPLICATION_JSON_VALUE
@@ -372,13 +420,39 @@ public ResponseEntity
- * No further checks are made if the extension is referenced by bundles or as a dependency.
+ * No dependency check is performed: referencing extensions are purged rather than blocking the
+ * operation.
*/
@Transactional(rollbackOn = ErrorResultException.class)
- public void deleteExtensionAndDependencies(UserData admin, String namespaceName, String extensionName)
+ public void purgeExtensionAndReferencingExtensions(UserData admin, String namespaceName, String extensionName)
throws ErrorResultException {
var extension = extensions.lockExtension(namespaceName, extensionName);
- deleteExtensionAndDependencies(admin, extension, 0);
+ purgeExtensionAndReferencingExtensions(admin, extension, new LinkedHashSet<>());
}
- private void deleteExtensionAndDependencies(UserData admin, Extension extension, int depth)
- throws ErrorResultException {
- if (depth > 5) {
- throw new ErrorResultException(
- "Failed to delete extension and its dependencies. Exceeded maximum recursion depth.",
- HttpStatus.INTERNAL_SERVER_ERROR);
- }
-
- var bundledRefs = repositories.findBundledExtensionsReference(extension);
- for (var bundledRef : bundledRefs) {
- deleteExtensionAndDependencies(admin, bundledRef, depth);
- }
-
- var dependRefs = repositories.findDependenciesReference(extension);
- for (var dependRef : dependRefs) {
- deleteExtensionAndDependencies(admin, dependRef, depth);
+ private void purgeExtensionAndReferencingExtensions(
+ UserData admin,
+ Extension extension,
+ Set
* The method will try to lock the extension and fail with an {@code ErrorResultException} if it can't acquire it.
*
@@ -384,6 +405,10 @@ public UserPublishInfoJson getUserPublishInfo(String provider, String loginName)
var json = latest.toExtensionJson();
json.setPreview(latest.isPreview());
json.setActive(latest.getExtension().isActive());
+ // findLatestVersions(user) includes inactive versions, which may be soft-deleted
+ // tombstones; surface that so the admin UI can distinguish removed from merely
+ // deactivated (mirrors UserAPI.getOwnExtensions and AdminAPI.getExtension).
+ json.setRemoved(latest.isRemoved());
json.setFiles(fileUrls.get(latest.getId()));
return json;
diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java
index e7a06315b..c957d5f88 100644
--- a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java
+++ b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java
@@ -87,6 +87,20 @@ public enum Type {
private boolean potentiallyMalicious;
+ /**
+ * Sticky tombstone marker. A removed version has been soft-deleted: it is hidden
+ * (also {@code active == false}) and its files have been stripped from storage, but the row is
+ * kept so its identity stays permanently reserved and can never be republished. Only an admin
+ * purge physically removes the row. Unlike {@code active}, this flag is never cleared by
+ * reactivation or scan processing.
+ */
+ private boolean removed;
+
+ private LocalDateTime removedTimestamp;
+
+ @ManyToOne
+ private UserData removedBy;
+
private String displayName;
@Column(length = 2048)
@@ -338,6 +352,30 @@ public void setPotentiallyMalicious(boolean potentiallyMalicious) {
this.potentiallyMalicious = potentiallyMalicious;
}
+ public boolean isRemoved() {
+ return removed;
+ }
+
+ public void setRemoved(boolean removed) {
+ this.removed = removed;
+ }
+
+ public LocalDateTime getRemovedTimestamp() {
+ return removedTimestamp;
+ }
+
+ public void setRemovedTimestamp(LocalDateTime removedTimestamp) {
+ this.removedTimestamp = removedTimestamp;
+ }
+
+ public UserData getRemovedBy() {
+ return removedBy;
+ }
+
+ public void setRemovedBy(UserData removedBy) {
+ this.removedBy = removedBy;
+ }
+
public String getDisplayName() {
return displayName;
}
@@ -512,6 +550,9 @@ public boolean equals(Object o) {
&& preview == that.preview
&& active == that.active
&& potentiallyMalicious == that.potentiallyMalicious
+ && removed == that.removed
+ && Objects.equals(removedTimestamp, that.removedTimestamp)
+ && Objects.equals(getId(removedBy), getId(that.removedBy)) // use id to prevent infinite recursion
&& Objects.equals(getId(extension), getId(that.extension)) // use id to prevent infinite recursion
&& Objects.equals(version, that.version)
&& Objects.equals(targetPlatform, that.targetPlatform)
@@ -552,6 +593,9 @@ public int hashCode() {
getId(publishedWith),
active,
potentiallyMalicious,
+ removed,
+ removedTimestamp,
+ getId(removedBy),
displayName,
description,
engines,
@@ -581,4 +625,8 @@ private Long getId(Extension extension) {
private Long getId(PersonalAccessToken token) {
return Optional.ofNullable(token).map(PersonalAccessToken::getId).orElse(null);
}
+
+ private Long getId(UserData user) {
+ return Optional.ofNullable(user).map(UserData::getId).orElse(null);
+ }
}
diff --git a/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java
index ca9dbf5e4..3668c74d6 100644
--- a/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java
+++ b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java
@@ -77,12 +77,12 @@ private void processMaliciousExtensions(JsonNode json) {
if (extensionId != null && repositories.hasExtension(extensionId.namespace(), extensionId.extension())) {
logger.info("delete malicious extension");
if (service.deleteTransitively) {
- admin.deleteExtensionAndDependencies(
+ admin.purgeExtensionAndReferencingExtensions(
extensionControlUser,
extensionId.namespace(),
extensionId.extension());
} else {
- admin.deleteExtension(extensionControlUser, extensionId.namespace(), extensionId.extension());
+ admin.purgeExtension(extensionControlUser, extensionId.namespace(), extensionId.extension());
}
}
}
diff --git a/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java
index a9ad61787..8e8449203 100644
--- a/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java
+++ b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java
@@ -15,6 +15,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
@@ -36,6 +37,7 @@
import org.eclipse.openvsx.repositories.RepositoryService;
import org.eclipse.openvsx.search.SearchUtilService;
import org.eclipse.openvsx.util.ExtensionId;
+import org.eclipse.openvsx.util.NamingUtil;
import org.eclipse.openvsx.util.TimeUtil;
import static org.eclipse.openvsx.cache.CacheService.CACHE_MALICIOUS_EXTENSIONS;
@@ -127,13 +129,32 @@ public void updateExtension(
}
var wasDeprecated = extension.isDeprecated();
+ var oldReplacement = extension.getReplacement();
extension.setDeprecated(deprecated);
extension.setDownloadable(downloadable);
if (replacementId != null) {
var replacement = repositories.findExtension(replacementId.extension(), replacementId.namespace());
- extension.setReplacement(replacement);
+ if (replacement == null || !replacement.isActive()) {
+ // Never point at a replacement that does not exist or has no active version; such a
+ // pointer would surface a dead replacement link on the extension.
+ if (replacement != null) {
+ logger.info(
+ "Ignoring inactive replacement {} configured for {}",
+ NamingUtil.toExtensionId(replacement),
+ NamingUtil.toExtensionId(extension));
+ }
+ extension.setReplacement(null);
+ } else {
+ extension.setReplacement(replacement);
+ }
}
- if (deprecated != wasDeprecated) {
+
+ // The replacement is part of the (cached) extension JSON, so evict when it changes too, not
+ // only when the deprecated flag flips. Compare by id, as the entity equals() is identity-based.
+ var replacementChanged = !Objects.equals(
+ oldReplacement != null ? oldReplacement.getId() : null,
+ extension.getReplacement() != null ? extension.getReplacement().getId() : null);
+ if (deprecated != wasDeprecated || replacementChanged) {
cache.evictNamespaceDetails(extension);
cache.evictLatestExtensionVersion(extension);
cache.evictExtensionJsons(extension);
diff --git a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java
index 684d9a6b2..5420925a5 100644
--- a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java
+++ b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java
@@ -89,6 +89,9 @@ public static ExtensionJson error(String message) {
@Schema(hidden = true)
private Boolean active;
+ @Schema(hidden = true)
+ private Boolean removed;
+
@Schema(
description = "Review/publishing status: published (active and visible to all), under_review (being reviewed), rejected (blocked)",
allowableValues = { "published", "under_review", "rejected" }
@@ -319,6 +322,14 @@ public void setActive(Boolean active) {
this.active = active;
}
+ public Boolean getRemoved() {
+ return removed;
+ }
+
+ public void setRemoved(Boolean removed) {
+ this.removed = removed;
+ }
+
public String getReviewStatus() {
return reviewStatus;
}
@@ -650,6 +661,7 @@ public boolean equals(Object o) {
&& Objects.equals(preRelease, that.preRelease)
&& Objects.equals(publishedBy, that.publishedBy)
&& Objects.equals(active, that.active)
+ && Objects.equals(removed, that.removed)
&& Objects.equals(reviewStatus, that.reviewStatus)
&& Objects.equals(reviewMessage, that.reviewMessage)
&& Objects.equals(verified, that.verified)
@@ -701,6 +713,7 @@ public int hashCode() {
preRelease,
publishedBy,
active,
+ removed,
reviewStatus,
reviewMessage,
verified,
diff --git a/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java b/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java
index 8450e3e2b..b1c0f986e 100644
--- a/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java
+++ b/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java
@@ -40,16 +40,6 @@ public static NamespaceJson error(String message) {
@NotNull
private Boolean verified;
- /**
- * @deprecated
- */
- @Schema(
- description = "Access level of the namespace. Deprecated: namespaces are now always restricted",
- allowableValues = { "public", "restricted" }
- )
- @Deprecated
- private String access;
-
@Schema(hidden = true)
private String membersUrl;
@@ -83,14 +73,6 @@ public void setVerified(Boolean verified) {
this.verified = verified;
}
- public String getAccess() {
- return access;
- }
-
- public void setAccess(String access) {
- this.access = access;
- }
-
public String getMembersUrl() {
return membersUrl;
}
diff --git a/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java b/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java
index 52d9ff6c0..7472982af 100644
--- a/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java
+++ b/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java
@@ -20,6 +20,7 @@
*
* @param targetPlatform Name of the target platform
* @param active Whether this target platform version is active
+ * @param removed Whether this target platform version has been removed (soft-deleted)
*/
@Schema(
name = "TargetPlatformActive",
@@ -43,5 +44,8 @@ public record TargetPlatformActiveJson(
NAME_UNIVERSAL
}
) String targetPlatform,
- @Schema(description = "Whether this extension version for this target platform is active") boolean active
+ @Schema(description = "Whether this extension version for this target platform is active") boolean active,
+ @Schema(
+ description = "Whether this extension version for this target platform has been removed (soft-deleted)"
+ ) boolean removed
){}
diff --git a/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java b/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java
index f107de4a9..9481f8cd2 100644
--- a/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java
+++ b/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java
@@ -11,4 +11,28 @@
import java.util.List;
-public record VersionTargetPlatformsJson(String version, List
+ * The container is a JVM-wide singleton: it is started exactly once (in the static initializer) and
+ * shared by every test context, instead of being a context-scoped {@code @ServiceConnection} bean that
+ * Spring would start and stop for each distinct application context. With half a dozen distinct
+ * {@code @SpringBootTest} context configurations this turned six Postgres startups (and six full Flyway
+ * migration runs) into one. Testcontainers' Ryuk sidecar stops the container when the JVM exits, so no
+ * explicit shutdown is required.
+ *
+ * Because all contexts now share a single database, tests must keep cleaning up after themselves (via
+ * transactional rollback or an explicit tear-down) and use unique identifiers, exactly as they already
+ * had to when sharing a context.
+ */
+@Tag("integration")
+public abstract class AbstractPostgresContainerTest {
+
+ static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.2");
+
+ static {
+ POSTGRES.start();
+ }
+
+ @DynamicPropertySource
+ static void datasourceProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/ExtensionDeleteTest.java b/server/src/test/java/org/eclipse/openvsx/ExtensionDeleteTest.java
index d1cf78c5d..01943d04b 100644
--- a/server/src/test/java/org/eclipse/openvsx/ExtensionDeleteTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/ExtensionDeleteTest.java
@@ -22,7 +22,6 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.transaction.PlatformTransactionManager;
@@ -53,17 +52,9 @@
* The race condition is deliberately triggered by intercepting the boundary call that
* separates the check from the act (using a spy), pausing the delete operation just
* long enough for the concurrent publish to commit.
- *
- * {@link ExtensionService#deleteUserExtension(UserData, String, String, TargetPlatformVersion...)}
- * is protected by a {@code SELECT … FOR UPDATE NOWAIT} lock and therefore passes.
*/
-@SpringBootTest(
- properties = {
- "ovsx.elasticsearch.enabled=false"
- }
-)
-@ActiveProfiles("test_db")
-class ExtensionDeleteTest {
+@SpringBootTest
+class ExtensionDeleteTest extends AbstractPostgresContainerTest {
private static final String NAMESPACE = "race-testns";
private static final String EXTENSION = "race-testext";
@@ -141,7 +132,7 @@ void userDeleteExtension_doesNotDeleteAnotherPublishersConcurrentlyAddedVersion(
publisherFinished.await(RACE_WINDOW_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
return result;
- }).when(repositories).isDeleteAllVersions(any(), any(), any(), any());
+ }).when(repositories).isDeleteAllActiveVersions(any(), any(), any(), any());
var owner = new UserData();
owner.setId(ownerId);
@@ -196,8 +187,11 @@ void deleteExtension_keepsExtensionAndOtherUsersVersionWhenDeletingOwnVersion()
.as("another publisher's version must not be removed or orphaned")
.isTrue();
assertThat(versionExists("1.0.0"))
- .as("the owner's deleted version must be gone")
- .isFalse();
+ .as("the owner's deleted version row must be kept as an immutable tombstone")
+ .isTrue();
+ assertThat(versionRemoved("1.0.0"))
+ .as("the owner's deleted version must be marked removed and inactive")
+ .isTrue();
}
/**
@@ -285,6 +279,20 @@ private boolean versionExists(String version) {
.isEmpty()));
}
+ private boolean versionRemoved(String version) {
+ return Boolean.TRUE.equals(
+ new TransactionTemplate(txManager).execute(
+ status -> !em.createQuery(
+ "select ev.id from ExtensionVersion ev "
+ + "where ev.version = :version "
+ + "and ev.extension.namespace.name = :namespace "
+ + "and ev.removed = true and ev.active = false")
+ .setParameter("version", version)
+ .setParameter("namespace", NAMESPACE)
+ .getResultList()
+ .isEmpty()));
+ }
+
private boolean extensionExists() {
return Boolean.TRUE.equals(
new TransactionTemplate(txManager).execute(
diff --git a/server/src/test/java/org/eclipse/openvsx/ExtensionServiceTest.java b/server/src/test/java/org/eclipse/openvsx/ExtensionServiceTest.java
index 8e360e0bc..a1ac366db 100644
--- a/server/src/test/java/org/eclipse/openvsx/ExtensionServiceTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/ExtensionServiceTest.java
@@ -137,6 +137,32 @@ void shouldReactivateExtensionsWithQuarantinedScansAndAllowed() {
assertThat(extVersion.isActive()).isTrue();
}
+ @Test
+ void shouldNotReactivateRemovedVersions() {
+ var user = mockUser();
+ var ext = mockExtension();
+
+ // A soft-deleted (removed) version is a permanent tombstone: even though it is inactive (and would
+ // otherwise be a reactivation candidate), it must never be brought back to life.
+ var extVersion = new ExtensionVersion();
+ extVersion.setId(1L);
+ extVersion.setVersion("1.1.0");
+ extVersion.setTargetPlatform("linux");
+ extVersion.setActive(false);
+ extVersion.setRemoved(true);
+ extVersion.setExtension(ext);
+ ext.getVersions().add(extVersion);
+
+ Mockito.when(repositories.findVersionsByUser(user, false)).thenReturn(Streamable.of(extVersion));
+
+ svc.reactivateExtensions(user);
+
+ assertThat(extVersion.isActive()).isFalse();
+ assertThat(ext.isActive()).isFalse();
+ // A tombstone is rejected up front, so its scan state is never even consulted.
+ Mockito.verify(repositories, Mockito.never()).findLatestExtensionScan(Mockito.any());
+ }
+
// ---------- UTILITY ----------//
private Extension mockExtension() {
diff --git a/server/src/test/java/org/eclipse/openvsx/ExtensionSoftDeleteTest.java b/server/src/test/java/org/eclipse/openvsx/ExtensionSoftDeleteTest.java
new file mode 100644
index 000000000..88a56c092
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/ExtensionSoftDeleteTest.java
@@ -0,0 +1,531 @@
+/********************************************************************************
+ * Copyright (c) 2026 Eclipse Foundation and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+package org.eclipse.openvsx;
+
+import java.time.LocalDateTime;
+
+import jakarta.persistence.EntityManager;
+import org.jobrunr.scheduling.JobRequestScheduler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.util.Streamable;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import org.eclipse.openvsx.entities.Extension;
+import org.eclipse.openvsx.entities.ExtensionVersion;
+import org.eclipse.openvsx.entities.Namespace;
+import org.eclipse.openvsx.entities.PersonalAccessToken;
+import org.eclipse.openvsx.entities.UserData;
+import org.eclipse.openvsx.repositories.RepositoryService;
+import org.eclipse.openvsx.search.SearchUtilService;
+import org.eclipse.openvsx.util.ErrorResultException;
+import org.eclipse.openvsx.util.TargetPlatform;
+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.Mockito.doReturn;
+
+/**
+ * Integration tests for the soft-delete (immutable version) feature in {@link ExtensionService}.
+ *
+ * A "deleted" extension version is soft-deleted: its row is kept as a permanent tombstone (marked
+ * {@code removed} and inactive, with its files stripped from storage) so the version identity stays
+ * reserved and can never be republished. Only a purge physically removes the row and frees the identity.
+ * These tests exercise the end-to-end behaviour against a real database, as well as the query paths that
+ * must exclude tombstones from public surfaces.
+ */
+@SpringBootTest
+class ExtensionSoftDeleteTest extends AbstractPostgresContainerTest {
+
+ private static final String NAMESPACE = "soft-delete-testns";
+ private static final String EXTENSION = "soft-delete-testext";
+ private static final String OWNER_LOGIN = "soft-delete-owner";
+
+ @Autowired
+ ExtensionService extensionService;
+
+ @MockitoSpyBean
+ RepositoryService repositories;
+
+ @Autowired
+ EntityManager em;
+
+ @Autowired
+ PlatformTransactionManager txManager;
+
+ @MockitoBean
+ SearchUtilService search;
+
+ @MockitoBean
+ JobRequestScheduler scheduler;
+
+ private long extensionId;
+ private long ownerId;
+ private long ownerTokenId;
+
+ @BeforeEach
+ void setUp() {
+ new TransactionTemplate(txManager).executeWithoutResult(status -> {
+ var owner = new UserData();
+ owner.setLoginName(OWNER_LOGIN);
+ em.persist(owner);
+
+ var token = new PersonalAccessToken();
+ token.setUser(owner);
+ token.setValue(OWNER_LOGIN + "_token");
+ token.setCreatedTimestamp(LocalDateTime.now());
+ token.setActive(true);
+ em.persist(token);
+
+ var namespace = new Namespace();
+ namespace.setName(NAMESPACE);
+ em.persist(namespace);
+
+ var extension = new Extension();
+ extension.setName(EXTENSION);
+ extension.setNamespace(namespace);
+ extension.setActive(true);
+ em.persist(extension);
+ em.flush();
+
+ ownerId = owner.getId();
+ ownerTokenId = token.getId();
+ extensionId = extension.getId();
+ });
+ }
+
+ private UserData owner() {
+ var owner = new UserData();
+ owner.setId(ownerId);
+ owner.setLoginName(OWNER_LOGIN);
+ return owner;
+ }
+
+ /**
+ * Deleting a version while another exists soft-deletes it: the row survives as a tombstone that is
+ * marked removed, inactive, and stamped with the deleting user and a timestamp.
+ */
+ @Test
+ void deleteExtensionVersion_softDeletesKeepingTombstone() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ extensionService.deleteExtension(owner(), false, NAMESPACE, EXTENSION, targets);
+
+ assertThat(versionExists("1.0.0"))
+ .as("a soft-deleted version's row must be kept as an immutable tombstone")
+ .isTrue();
+ assertThat(versionRemoved("1.0.0"))
+ .as("a soft-deleted version must be marked removed and inactive")
+ .isTrue();
+ assertThat(removedByOf("1.0.0"))
+ .as("a soft-deleted version records who removed it")
+ .isEqualTo(ownerId);
+ assertThat(removedTimestampOf("1.0.0"))
+ .as("a soft-deleted version records when it was removed")
+ .isNotNull();
+ assertThat(versionRemoved("2.0.0"))
+ .as("an untouched version must stay live")
+ .isFalse();
+ }
+
+ /**
+ * Deleting an already-removed version is an idempotent no-op: it neither fails nor re-stamps the
+ * tombstone (the original removal metadata is preserved).
+ */
+ @Test
+ void deleteExtensionVersion_isIdempotentForAlreadyRemoved() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ extensionService.deleteExtension(owner(), false, NAMESPACE, EXTENSION, targets);
+ var firstTimestamp = removedTimestampOf("1.0.0");
+
+ // A second delete of the same version must not throw and must not touch the tombstone.
+ extensionService.deleteExtension(owner(), false, NAMESPACE, EXTENSION, targets);
+
+ assertThat(versionRemoved("1.0.0")).isTrue();
+ assertThat(removedTimestampOf("1.0.0"))
+ .as("re-deleting a tombstone must not re-stamp its removal timestamp")
+ .isEqualTo(firstTimestamp);
+ }
+
+ /**
+ * Purging permanently removes the version row (unlike soft-delete), freeing the identity.
+ */
+ @Test
+ void purgeExtensionVersion_physicallyRemovesRow() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION, targets);
+
+ assertThat(versionExists("1.0.0"))
+ .as("a purged version's row must be physically removed")
+ .isFalse();
+ assertThat(versionExists("2.0.0"))
+ .as("an untouched version must survive the purge")
+ .isTrue();
+ }
+
+ /**
+ * Soft-deleting then purging the same version leaves no row behind, freeing the identity.
+ */
+ @Test
+ void softDeleteThenPurge_removesTombstone() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ extensionService.deleteExtension(owner(), false, NAMESPACE, EXTENSION, targets);
+ assertThat(versionRemoved("1.0.0")).isTrue();
+
+ extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION, targets);
+ assertThat(versionExists("1.0.0"))
+ .as("purging a tombstone must physically remove its row")
+ .isFalse();
+ }
+
+ /**
+ * Fix #2: the version listing used by the public {@code /versions} API must never surface tombstones.
+ */
+ @Test
+ void findActiveVersionsSorted_excludesRemovedVersions() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, false, true);
+
+ var page = repositories.findActiveVersionsSorted(NAMESPACE, EXTENSION, PageRequest.of(0, 10));
+ var versions = page.getContent().stream().map(ExtensionVersion::getVersion).toList();
+
+ assertThat(versions)
+ .as("the public versions listing must exclude soft-deleted versions")
+ .contains("1.0.0")
+ .doesNotContain("2.0.0");
+ }
+
+ /**
+ * Fix #3: the download URL map must never surface tombstones (whose files are gone anyway).
+ */
+ @Test
+ void findVersionsForUrls_excludesRemovedVersions() {
+ // Same version string, two target platforms: the universal one is live, the linux one is removed.
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("1.0.0", TargetPlatform.NAME_LINUX_X64, false, true);
+
+ var extension = repositories.findExtension(EXTENSION, NAMESPACE);
+ var forUrls = repositories.findVersionsForUrls(extension, null, "1.0.0");
+ var platforms = forUrls.stream().map(ExtensionVersion::getTargetPlatform).toList();
+
+ assertThat(platforms)
+ .as("the download map must only include active target platforms, never tombstones")
+ .containsExactly(TargetPlatform.NAME_UNIVERSAL);
+ }
+
+ /**
+ * The dependency guard must fire when a delete removes the last active versions, even if
+ * older tombstones still occupy rows. Regression test for the flaw where the guard was keyed on the
+ * total row count (tombstones included) instead of the active versions, so deleting all active
+ * versions of a depended-on extension slipped through without the check.
+ */
+ @Test
+ void deleteExtension_runsDependencyCheckWhenDeletingAllActiveVersionsDespiteTombstone() {
+ persistVersion("0.9.0", TargetPlatform.NAME_UNIVERSAL, false, true); // pre-existing tombstone
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false); // the only active version
+
+ // Simulate another extension depending on this one, so the dependency guard must reject.
+ doReturn(Streamable.of(dependantReference())).when(repositories).findDependenciesReference(any());
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ assertThatThrownBy(() -> extensionService.deleteExtension(owner(), false, NAMESPACE, EXTENSION, targets))
+ .as("deleting all active versions of a depended-on extension must run the dependency check")
+ .isInstanceOf(ErrorResultException.class);
+
+ assertThat(versionRemoved("1.0.0"))
+ .as("a rejected delete must leave the active version untouched")
+ .isFalse();
+ assertThat(versionActive("1.0.0"))
+ .as("the active version must survive the rejected delete")
+ .isTrue();
+ }
+
+ /**
+ * Deleting only a subset of the active versions is not a delete-all, so the dependency guard must
+ * not fire: the selected version is soft-deleted and the remaining active version stays live.
+ */
+ @Test
+ void deleteExtension_skipsDependencyCheckWhenDeletingSubsetOfActiveVersions() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ // Even if a dependency exists, deleting a subset must not trigger the guard.
+ doReturn(Streamable.of(dependantReference())).when(repositories).findDependenciesReference(any());
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ extensionService.deleteExtension(owner(), false, NAMESPACE, EXTENSION, targets);
+
+ assertThat(versionRemoved("1.0.0"))
+ .as("deleting a subset must soft-delete the selected version without a dependency check")
+ .isTrue();
+ assertThat(versionActive("2.0.0"))
+ .as("the remaining active version must stay live")
+ .isTrue();
+ }
+
+ /**
+ * Purging explicit versions must remove only those versions: pre-existing tombstones and the
+ * extension record itself must survive so reserved identities stay reserved.
+ */
+ @Test
+ void purgeExtension_withExplicitTargets_keepsExtensionAndTombstones() {
+ persistVersion("0.9.0", TargetPlatform.NAME_UNIVERSAL, false, true); // pre-existing tombstone
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION, targets);
+
+ assertThat(versionExists("1.0.0"))
+ .as("the purged version's row must be physically removed")
+ .isFalse();
+ assertThat(versionExists("0.9.0"))
+ .as("a tombstone the caller did not select must survive a scoped purge")
+ .isTrue();
+ assertThat(extensionExists())
+ .as("a scoped purge must not remove the extension record itself")
+ .isTrue();
+ }
+
+ /**
+ * Purging every version of an extension by naming them all removes the extension as a whole,
+ * so its record is not left orphaned.
+ */
+ @Test
+ void purgeExtension_purgingAllVersionsRemovesExtension() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ var targets = new TargetPlatformVersion[] {
+ TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0"),
+ TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "2.0.0")
+ };
+ extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION, targets);
+
+ assertThat(versionExists("1.0.0")).isFalse();
+ assertThat(versionExists("2.0.0")).isFalse();
+ assertThat(extensionExists())
+ .as("purging all versions of an extension must remove the extension record too")
+ .isFalse();
+ }
+
+ /**
+ * The dependency guard must also fire on the purge path when purging all active versions.
+ */
+ @Test
+ void purgeExtension_runsDependencyCheckWhenPurgingAllActiveVersions() {
+ persistVersion("0.9.0", TargetPlatform.NAME_UNIVERSAL, false, true); // pre-existing tombstone
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ // Simulate another extension depending on this one.
+ doReturn(Streamable.of(dependantReference())).when(repositories).findDependenciesReference(any());
+
+ var targets = TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0");
+ assertThatThrownBy(() -> extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION, targets))
+ .as("purging all active versions of a depended-on extension must run the dependency check")
+ .isInstanceOf(ErrorResultException.class);
+
+ assertThat(versionExists("1.0.0"))
+ .as("a rejected purge must leave the active version in place")
+ .isTrue();
+ assertThat(versionExists("0.9.0"))
+ .as("a rejected purge must leave tombstones in place")
+ .isTrue();
+ }
+
+ /**
+ * Duplicate target versions must not inflate the "all active versions" check: purging the same
+ * subset version twice must be treated as purging that single version, so the dependency guard
+ * (which only applies when all active versions are removed) does not fire and the other version
+ * survives. Regression test for the duplicate-driven miscount.
+ */
+ @Test
+ void purgeExtension_deDuplicatesTargetsBeforeCounting() {
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+ persistVersion("2.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ // The extension is depended on: the guard would reject removing ALL active versions.
+ doReturn(Streamable.of(dependantReference())).when(repositories).findDependenciesReference(any());
+
+ var duplicate = new TargetPlatformVersion[] {
+ TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0"),
+ TargetPlatformVersion.of(TargetPlatform.NAME_UNIVERSAL, "1.0.0")
+ };
+ // Only a single distinct version is targeted, so this is not an "all versions" purge and must
+ // succeed without tripping the dependency guard.
+ extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION, duplicate);
+
+ assertThat(versionExists("1.0.0"))
+ .as("the named version must be purged")
+ .isFalse();
+ assertThat(versionExists("2.0.0"))
+ .as("a version that was not named must survive")
+ .isTrue();
+ assertThat(extensionExists())
+ .as("purging a subset must not remove the extension record")
+ .isTrue();
+ }
+
+ /**
+ * A purge with no target versions purges nothing: versions must be named explicitly (the
+ * whole-extension shortcut was removed).
+ */
+ @Test
+ void purgeExtension_withEmptyTargetsIsNoOp() {
+ persistVersion("0.9.0", TargetPlatform.NAME_UNIVERSAL, false, true); // pre-existing tombstone
+ persistVersion("1.0.0", TargetPlatform.NAME_UNIVERSAL, true, false);
+
+ extensionService.purgeExtensionNoWait(owner(), NAMESPACE, EXTENSION);
+
+ assertThat(versionExists("1.0.0"))
+ .as("an empty-target purge must not remove any version")
+ .isTrue();
+ assertThat(versionExists("0.9.0"))
+ .as("an empty-target purge must not remove tombstones")
+ .isTrue();
+ assertThat(extensionExists())
+ .as("an empty-target purge must not remove the extension record")
+ .isTrue();
+ }
+
+ private ExtensionVersion dependantReference() {
+ var namespace = new Namespace();
+ namespace.setName("dependant-ns");
+ var extension = new Extension();
+ extension.setName("dependant-ext");
+ extension.setNamespace(namespace);
+ var extVersion = new ExtensionVersion();
+ extVersion.setExtension(extension);
+ extVersion.setVersion("1.0.0");
+ extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL);
+ return extVersion;
+ }
+
+ private void persistVersion(String version, String targetPlatform, boolean active, boolean removed) {
+ new TransactionTemplate(txManager).executeWithoutResult(status -> {
+ var extension = em.find(Extension.class, extensionId);
+ var token = em.getReference(PersonalAccessToken.class, ownerTokenId);
+ var extVersion = new ExtensionVersion();
+ extVersion.setVersion(version);
+ extVersion.setTargetPlatform(targetPlatform);
+ extVersion.setExtension(extension);
+ extVersion.setPublishedWith(token);
+ extVersion.setActive(active);
+ extVersion.setRemoved(removed);
+ if (removed) {
+ extVersion.setActive(false);
+ extVersion.setRemovedTimestamp(LocalDateTime.now());
+ extVersion.setRemovedBy(em.getReference(UserData.class, ownerId));
+ }
+ em.persist(extVersion);
+ });
+ }
+
+ private boolean versionExists(String version) {
+ return count(
+ "select ev.id from ExtensionVersion ev where ev.version = :version "
+ + "and ev.extension.namespace.name = :namespace",
+ version) > 0;
+ }
+
+ private boolean versionRemoved(String version) {
+ return count(
+ "select ev.id from ExtensionVersion ev where ev.version = :version "
+ + "and ev.extension.namespace.name = :namespace and ev.removed = true and ev.active = false",
+ version) > 0;
+ }
+
+ private boolean versionActive(String version) {
+ return count(
+ "select ev.id from ExtensionVersion ev where ev.version = :version "
+ + "and ev.extension.namespace.name = :namespace and ev.active = true and ev.removed = false",
+ version) > 0;
+ }
+
+ private boolean extensionExists() {
+ return Boolean.TRUE.equals(
+ new TransactionTemplate(txManager).execute(
+ status -> !em.createQuery(
+ "select e.id from Extension e "
+ + "where e.name = :name and e.namespace.name = :namespace")
+ .setParameter("name", EXTENSION)
+ .setParameter("namespace", NAMESPACE)
+ .getResultList()
+ .isEmpty()));
+ }
+
+ private Long removedByOf(String version) {
+ return new TransactionTemplate(txManager).execute(
+ status -> em.createQuery(
+ "select ev.removedBy.id from ExtensionVersion ev where ev.version = :version "
+ + "and ev.extension.namespace.name = :namespace",
+ Long.class)
+ .setParameter("version", version)
+ .setParameter("namespace", NAMESPACE)
+ .getSingleResult());
+ }
+
+ private LocalDateTime removedTimestampOf(String version) {
+ return new TransactionTemplate(txManager).execute(
+ status -> em.createQuery(
+ "select ev.removedTimestamp from ExtensionVersion ev where ev.version = :version "
+ + "and ev.extension.namespace.name = :namespace",
+ LocalDateTime.class)
+ .setParameter("version", version)
+ .setParameter("namespace", NAMESPACE)
+ .getSingleResult());
+ }
+
+ private long count(String jpql, String version) {
+ return new TransactionTemplate(txManager).execute(
+ status -> (long) em.createQuery(jpql)
+ .setParameter("version", version)
+ .setParameter("namespace", NAMESPACE)
+ .getResultList()
+ .size());
+ }
+
+ @AfterEach
+ void tearDown() {
+ new TransactionTemplate(txManager).executeWithoutResult(status -> {
+ em.createQuery("delete from ExtensionVersion ev where ev.extension.namespace.name = :namespace")
+ .setParameter("namespace", NAMESPACE).executeUpdate();
+ em.createQuery("delete from Extension e where e.namespace.name = :namespace")
+ .setParameter("namespace", NAMESPACE).executeUpdate();
+ em.createQuery("delete from PersistedLog pl where pl.user.loginName = :login")
+ .setParameter("login", OWNER_LOGIN).executeUpdate();
+ em.createQuery("delete from PersonalAccessToken t where t.user.loginName = :login")
+ .setParameter("login", OWNER_LOGIN).executeUpdate();
+ em.createQuery("delete from Namespace n where n.name = :namespace")
+ .setParameter("namespace", NAMESPACE).executeUpdate();
+ em.createQuery("delete from UserData u where u.loginName = :login")
+ .setParameter("login", OWNER_LOGIN).executeUpdate();
+ });
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/IntegrationTest.java b/server/src/test/java/org/eclipse/openvsx/IntegrationTest.java
index 79222f0d4..fab7457a7 100644
--- a/server/src/test/java/org/eclipse/openvsx/IntegrationTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/IntegrationTest.java
@@ -33,8 +33,8 @@
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
-@ActiveProfiles({ "test", "test_db", "test_search" })
-class IntegrationTest {
+@ActiveProfiles({ "test", "test_search" })
+class IntegrationTest extends AbstractPostgresContainerTest {
protected final Logger logger = LoggerFactory.getLogger(IntegrationTest.class);
diff --git a/server/src/test/java/org/eclipse/openvsx/TestDatabaseConfig.java b/server/src/test/java/org/eclipse/openvsx/TestDatabaseConfig.java
deleted file mode 100644
index 12d20ab5a..000000000
--- a/server/src/test/java/org/eclipse/openvsx/TestDatabaseConfig.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/******************************************************************************
- * 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;
-
-import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Profile;
-import org.testcontainers.postgresql.PostgreSQLContainer;
-
-@Configuration
-@Profile("test_db")
-public class TestDatabaseConfig {
-
- @Bean
- @ServiceConnection
- PostgreSQLContainer postgreSQLContainer() {
- return new PostgreSQLContainer("postgres:16.2");
- }
-}
diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java
index 3712a2b7d..923cf870d 100644
--- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java
+++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java
@@ -72,6 +72,7 @@
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.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserAPI.class)
@@ -109,6 +110,9 @@ class UserAPITest {
@MockitoBean
RepositoryService repositories;
+ @Autowired
+ StorageUtilService storageUtil;
+
@MockitoBean
org.eclipse.openvsx.repositories.ExtensionScanRepository scanRepository;
@@ -278,7 +282,14 @@ void testOwnNamespacesNotLoggedIn() throws Exception {
@Test
void testOwnExtension() throws Exception {
var userData = mockUserData();
- mockExtension(userData, 2, 0, 0);
+ var versions = mockExtension(userData, 2, 0, 0);
+ var namespace = versions.getLast().getExtension().getNamespace();
+ // The user is still a member of the namespace, so their published extension is listed.
+ var membership = new NamespaceMembership();
+ membership.setNamespace(namespace);
+ membership.setUser(userData);
+ membership.setRole(NamespaceMembership.ROLE_CONTRIBUTOR);
+ Mockito.when(repositories.findMemberships(userData)).thenReturn(Streamable.of(membership));
mockMvc.perform(
get("/user/extensions")
.with(user("test_user")))
@@ -291,6 +302,20 @@ void testOwnExtension() throws Exception {
})));
}
+ @Test
+ void testOwnExtensionExcludesNonMemberNamespace() throws Exception {
+ var userData = mockUserData();
+ mockExtension(userData, 2, 0, 0);
+ // The user published the extension but is no longer a member of its namespace: it must not
+ // be listed. findMemberships returns nothing, so the published extension is filtered out.
+ Mockito.when(repositories.findMemberships(userData)).thenReturn(Streamable.empty());
+ mockMvc.perform(
+ get("/user/extensions")
+ .with(user("test_user")))
+ .andExpect(status().isOk())
+ .andExpect(content().json("[]"));
+ }
+
@Test
void testOwnExtensionNotLoggedIn() throws Exception {
var userData = mockUserData();
@@ -299,6 +324,129 @@ void testOwnExtensionNotLoggedIn() throws Exception {
.andExpect(status().isForbidden());
}
+ @Test
+ void testGetOwnExtensionAsNamespaceOwner() throws Exception {
+ var userData = mockUserData();
+ var versions = mockExtension(userData, 2, 0, 0);
+ var latest = versions.getLast();
+ latest.setId(42L);
+ var extension = latest.getExtension();
+ // The caller owns the namespace: the unscoped lookup is used and every version is deletable.
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.findLatestVersion(eq("foobar"), eq("baz"), any(), eq(false), eq(false)))
+ .thenReturn(latest);
+ Mockito.when(repositories.findTargetPlatformsGroupedByVersion(extension))
+ .thenReturn(
+ List.of(
+ new VersionTargetPlatformsJson(
+ "2.0.0",
+ List.of(
+ new TargetPlatformActiveJson(
+ TargetPlatform.NAME_UNIVERSAL,
+ true,
+ false))),
+ new VersionTargetPlatformsJson(
+ "1.0.0",
+ List.of(
+ new TargetPlatformActiveJson(
+ TargetPlatform.NAME_UNIVERSAL,
+ true,
+ false)))));
+ Mockito.when(
+ storageUtil.getFileUrls(
+ Mockito.anyCollection(),
+ Mockito.anyString(),
+ Mockito.any(String[].class)))
+ .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>()));
+ mockMvc.perform(
+ get("/user/extension/{namespace}/{extension}", "foobar", "baz")
+ .with(user("test_user")))
+ .andExpect(status().isOk())
+ .andExpect(content().json("{\"name\":\"baz\",\"namespace\":\"foobar\"}"))
+ .andExpect(jsonPath("$.allTargetPlatformVersions[0].canDelete").value(true))
+ .andExpect(jsonPath("$.allTargetPlatformVersions[1].canDelete").value(true));
+ }
+
+ @Test
+ void testGetOwnExtensionAsNamespaceMember() throws Exception {
+ var userData = mockUserData();
+ var versions = mockExtension(userData, 2, 0, 0);
+ var latest = versions.getLast();
+ latest.setId(42L);
+ var extension = latest.getExtension();
+ // The caller is a namespace member but not an owner: the unscoped lookup lets them see every
+ // version, but only the versions they published themselves are marked deletable.
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.findLatestVersion(eq("foobar"), eq("baz"), any(), eq(false), eq(false)))
+ .thenReturn(latest);
+ Mockito.when(repositories.findTargetPlatformsGroupedByVersion(extension))
+ .thenReturn(
+ List.of(
+ new VersionTargetPlatformsJson(
+ "2.0.0",
+ List.of(
+ new TargetPlatformActiveJson(
+ TargetPlatform.NAME_UNIVERSAL,
+ true,
+ false))),
+ new VersionTargetPlatformsJson(
+ "1.0.0",
+ List.of(
+ new TargetPlatformActiveJson(
+ TargetPlatform.NAME_UNIVERSAL,
+ true,
+ false)))));
+ // Only version 1.0.0 was published by this user.
+ Mockito.when(repositories.findTargetPlatformsGroupedByVersion(extension, userData))
+ .thenReturn(
+ List.of(
+ new VersionTargetPlatformsJson(
+ "1.0.0",
+ List.of(
+ new TargetPlatformActiveJson(
+ TargetPlatform.NAME_UNIVERSAL,
+ true,
+ false)))));
+ Mockito.when(
+ storageUtil.getFileUrls(
+ Mockito.anyCollection(),
+ Mockito.anyString(),
+ Mockito.any(String[].class)))
+ .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>()));
+ mockMvc.perform(
+ get("/user/extension/{namespace}/{extension}", "foobar", "baz")
+ .with(user("test_user")))
+ .andExpect(status().isOk())
+ .andExpect(content().json("{\"name\":\"baz\",\"namespace\":\"foobar\"}"))
+ .andExpect(jsonPath("$.allTargetPlatformVersions[0].version").value("2.0.0"))
+ .andExpect(jsonPath("$.allTargetPlatformVersions[0].canDelete").value(false))
+ .andExpect(jsonPath("$.allTargetPlatformVersions[1].version").value("1.0.0"))
+ .andExpect(jsonPath("$.allTargetPlatformVersions[1].canDelete").value(true));
+ }
+
+ @Test
+ void testGetOwnExtensionNotMember() throws Exception {
+ var userData = mockUserData();
+ mockExtension(userData, 2, 0, 0);
+ // Not a namespace member: no access at all, even to versions the user may have published
+ // while they were still a member => 404 without ever looking up any version.
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(false);
+ mockMvc.perform(
+ get("/user/extension/{namespace}/{extension}", "foobar", "baz")
+ .with(user("test_user")))
+ .andExpect(status().isNotFound());
+ Mockito.verify(repositories, Mockito.never())
+ .findLatestVersion(eq("foobar"), eq("baz"), any(), eq(false), eq(false));
+ }
+
+ @Test
+ void testGetOwnExtensionNotLoggedIn() throws Exception {
+ mockMvc.perform(get("/user/extension/{namespace}/{extension}", "foobar", "baz"))
+ .andExpect(status().isForbidden());
+ }
+
@Test
void testNamespaceMembers() throws Exception {
mockNamespaceMemberships(NamespaceMembership.ROLE_OWNER);
@@ -540,6 +688,10 @@ void testDeleteExtensionNotPublisher() throws Exception {
Mockito.doReturn(otherUser).when(users).findLoggedInUser();
mockExtension(userData, 2, 0, 0);
+ // A namespace member, but not the publisher of the targeted version: the version lookup is
+ // scoped to the caller, so it is not found and the delete fails with 404.
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
mockMvc.perform(
post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
.content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"}]")
@@ -550,24 +702,28 @@ void testDeleteExtensionNotPublisher() throws Exception {
}
@Test
- void testDeleteExtension() throws Exception {
+ void testDeleteExtensionNotMember() throws Exception {
var userData = mockUserData();
mockExtension(userData, 2, 0, 0);
+ // Neither owner nor member of the namespace: rejected before the extension is touched.
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(false);
mockMvc.perform(
post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
- .content(
- "[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]")
+ .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"}]")
.contentType(MediaType.APPLICATION_JSON)
.with(user("test_user"))
.with(csrf().asHeader()))
- .andExpect(status().isOk())
- .andExpect(content().json(successJson("Deleted foobar.baz")));
+ .andExpect(status().isForbidden());
}
@Test
- void testDeleteExtensionVersion() throws Exception {
+ void testDeleteExtension() throws Exception {
var userData = mockUserData();
- mockExtension(userData, 3, 0, 0);
+ mockExtension(userData, 2, 0, 0);
+ // A member deletes all versions they published by naming them explicitly; each is soft-deleted.
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
mockMvc.perform(
post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
.content(
@@ -580,23 +736,29 @@ void testDeleteExtensionVersion() throws Exception {
}
@Test
- void testDeleteLastExtensionVersion() throws Exception {
+ void testDeleteExtensionEmptyTargetsIsNoOp() throws Exception {
var userData = mockUserData();
- mockExtension(userData, 1, 0, 0);
+ mockExtension(userData, 2, 0, 0);
+ // With the whole-extension shortcut removed, an empty target list deletes nothing.
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
mockMvc.perform(
post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
- .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"}]")
+ .content("[]")
.contentType(MediaType.APPLICATION_JSON)
.with(user("test_user"))
.with(csrf().asHeader()))
.andExpect(status().isOk())
- .andExpect(content().json(successJson("Deleted foobar.baz")));
+ .andExpect(content().json(successJson("")));
}
@Test
- void testDeleteBundledExtension() throws Exception {
+ void testDeleteExtensionVersion() throws Exception {
var userData = mockUserData();
- mockExtension(userData, 2, 1, 0);
+ mockExtension(userData, 3, 0, 0);
+ // Non-owner member may delete versions they published themselves.
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
mockMvc.perform(
post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
.content(
@@ -604,17 +766,35 @@ void testDeleteBundledExtension() throws Exception {
.contentType(MediaType.APPLICATION_JSON)
.with(user("test_user"))
.with(csrf().asHeader()))
- .andExpect(status().isBadRequest())
- .andExpect(
- content().json(
- errorJson(
- "Extension foobar.baz is bundled by the following extension packs: foobar.bundle-1.0.0")));
+ .andExpect(status().isOk())
+ .andExpect(content().json(successJson("Deleted foobar.baz 1.0.0\nDeleted foobar.baz 2.0.0")));
+ }
+
+ @Test
+ void testDeleteLastExtensionVersion() throws Exception {
+ var userData = mockUserData();
+ mockExtension(userData, 1, 0, 0);
+ // Non-owner member deleting the last version they published: soft-deleted per version
+ // (the extension record itself survives, deactivated).
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
+ mockMvc.perform(
+ post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
+ .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"}]")
+ .contentType(MediaType.APPLICATION_JSON)
+ .with(user("test_user"))
+ .with(csrf().asHeader()))
+ .andExpect(status().isOk())
+ .andExpect(content().json(successJson("Deleted foobar.baz 1.0.0")));
}
@Test
void testDeleteDependingExtension() throws Exception {
var userData = mockUserData();
mockExtension(userData, 2, 0, 1);
+ // Deleting all active versions of a depended-on extension triggers the dependency check.
+ Mockito.when(repositories.hasMembership(any(UserData.class), any(Namespace.class))).thenReturn(true);
+ Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(false);
mockMvc.perform(
post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz")
.content(
@@ -814,11 +994,12 @@ private List
+ * The cascade that purges an extension together with every extension that references it (packs bundling it,
+ * extensions depending on it), walking the reverse-reference direction.
+ */
+@ExtendWith(MockitoExtension.class)
+class AdminServiceTest {
+
+ private static final String NAMESPACE = "n";
+
+ @Mock
+ RepositoryService repositories;
+
+ @Mock
+ ExtensionService extensions;
+
+ @InjectMocks
+ AdminService adminService;
+
+ private final UserData admin = new UserData();
+ private long idSequence = 0;
+
+ private Extension extension(String name) {
+ var namespace = new Namespace();
+ namespace.setName(NAMESPACE);
+ var extension = new Extension();
+ extension.setId(++idSequence);
+ extension.setName(name);
+ extension.setNamespace(namespace);
+ return extension;
+ }
+
+ private ExtensionVersion version(Extension extension) {
+ var extVersion = new ExtensionVersion();
+ extVersion.setId(++idSequence);
+ extVersion.setExtension(extension);
+ extVersion.setVersion("1.0.0");
+ extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL);
+ return extVersion;
+ }
+
+ private void mockNoReferences(Extension extension) {
+ when(repositories.findBundledExtensionsReference(extension)).thenReturn(Streamable.empty());
+ when(repositories.findDependenciesReference(extension)).thenReturn(Streamable.empty());
+ }
+
+ @Test
+ void purgesReferencingExtensionAsWholeWhenAllVersionsReference() {
+ var target = extension("target");
+ var referencing = extension("referencing");
+ // The referencing extension has two versions, both bundling the target.
+ var refV1 = version(referencing);
+ var refV2 = version(referencing);
+
+ when(extensions.lockExtension(NAMESPACE, "target")).thenReturn(target);
+ when(repositories.findBundledExtensionsReference(target)).thenReturn(Streamable.of(refV1, refV2));
+ when(repositories.findDependenciesReference(target)).thenReturn(Streamable.empty());
+ when(repositories.countVersions(NAMESPACE, "referencing")).thenReturn(2);
+ mockNoReferences(referencing);
+
+ adminService.purgeExtensionAndReferencingExtensions(admin, NAMESPACE, "target");
+
+ // The referencing extension is purged as a whole (not version-by-version) so nothing is orphaned.
+ verify(extensions).purgeExtension(admin, referencing, false);
+ verify(extensions).purgeExtension(admin, target, false);
+ verify(extensions, never()).purgeExtensionVersion(any(), any());
+ }
+
+ @Test
+ void purgesOnlyReferencingVersionsWhenSomeVersionsReference() {
+ var target = extension("target");
+ var referencing = extension("referencing");
+ // Only one of the referencing extension's three versions bundles the target.
+ var refV1 = version(referencing);
+
+ when(extensions.lockExtension(NAMESPACE, "target")).thenReturn(target);
+ when(repositories.findBundledExtensionsReference(target)).thenReturn(Streamable.of(refV1));
+ when(repositories.findDependenciesReference(target)).thenReturn(Streamable.empty());
+ when(repositories.countVersions(NAMESPACE, "referencing")).thenReturn(3);
+
+ adminService.purgeExtensionAndReferencingExtensions(admin, NAMESPACE, "target");
+
+ // Only the referencing version is purged; the extension keeps its other versions.
+ verify(extensions).purgeExtensionVersion(admin, refV1);
+ verify(extensions, never()).purgeExtension(admin, referencing, false);
+ verify(extensions).purgeExtension(admin, target, false);
+ }
+
+ @Test
+ void handlesReferenceCyclesWithoutInfiniteRecursion() {
+ // a and b bundle each other; both are single-version extensions.
+ var a = extension("a");
+ var b = extension("b");
+ var aV = version(a);
+ var bV = version(b);
+
+ when(extensions.lockExtension(NAMESPACE, "a")).thenReturn(a);
+ // versions bundling a -> b's version; versions bundling b -> a's version
+ when(repositories.findBundledExtensionsReference(a)).thenReturn(Streamable.of(bV));
+ when(repositories.findDependenciesReference(a)).thenReturn(Streamable.empty());
+ when(repositories.findBundledExtensionsReference(b)).thenReturn(Streamable.of(aV));
+ when(repositories.findDependenciesReference(b)).thenReturn(Streamable.empty());
+ when(repositories.countVersions(NAMESPACE, "a")).thenReturn(1);
+ when(repositories.countVersions(NAMESPACE, "b")).thenReturn(1);
+
+ assertThatCode(() -> adminService.purgeExtensionAndReferencingExtensions(admin, NAMESPACE, "a"))
+ .doesNotThrowAnyException();
+
+ verify(extensions).purgeExtension(admin, a, false);
+ verify(extensions).purgeExtension(admin, b, false);
+ verify(extensions, never()).purgeExtensionVersion(any(), any());
+ }
+
+ @Test
+ void purgesDeepReferenceChainWithoutDepthLimit() {
+ // Chain of 8 single-version extensions where each references the previous one; the previous
+ // depth limit (> 5) would have aborted this legitimate chain, the visited-set does not.
+ var chain = new Extension[8];
+ var chainVersions = new ExtensionVersion[8];
+ for (var i = 0; i < chain.length; i++) {
+ chain[i] = extension("e" + i);
+ chainVersions[i] = version(chain[i]);
+ }
+
+ when(extensions.lockExtension(NAMESPACE, "e0")).thenReturn(chain[0]);
+ for (var i = 0; i < chain.length; i++) {
+ // chain[i+1] references chain[i]
+ when(repositories.findBundledExtensionsReference(chain[i]))
+ .thenReturn(i + 1 < chain.length ? Streamable.of(chainVersions[i + 1]) : Streamable.empty());
+ when(repositories.findDependenciesReference(chain[i])).thenReturn(Streamable.empty());
+ if (i > 0) {
+ when(repositories.countVersions(NAMESPACE, "e" + i)).thenReturn(1);
+ }
+ }
+
+ assertThatCode(() -> adminService.purgeExtensionAndReferencingExtensions(admin, NAMESPACE, "e0"))
+ .doesNotThrowAnyException();
+
+ for (var extension : chain) {
+ verify(extensions).purgeExtension(admin, extension, false);
+ }
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java b/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java
index 2c0b9f9de..a5d811c49 100644
--- a/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java
@@ -31,6 +31,7 @@
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
+import org.eclipse.openvsx.AbstractPostgresContainerTest;
import org.eclipse.openvsx.ExtensionService;
import org.eclipse.openvsx.LocalRegistryService;
import org.eclipse.openvsx.UserService;
@@ -51,13 +52,10 @@
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(
- webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
- properties = {
- "ovsx.elasticsearch.enabled=false"
- }
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
-@ActiveProfiles({ "test", "test_db" })
-class CacheServiceTest {
+@ActiveProfiles("test")
+class CacheServiceTest extends AbstractPostgresContainerTest {
@Autowired
CacheManager cache;
@@ -289,7 +287,11 @@ void testDeleteExtension() throws IOException {
extVersion.getTargetPlatform(),
extVersion.getVersion());
- admins.deleteExtensionNoWait(admin, namespace.getName(), extension.getName());
+ admins.deleteExtensionNoWait(
+ admin,
+ namespace.getName(),
+ extension.getName(),
+ TargetPlatformVersion.of(extVersion.getTargetPlatform(), extVersion.getVersion()));
assertNull(getCache(CACHE_EXTENSION_JSON).get(cacheKey, ExtensionJson.class));
}
}
diff --git a/server/src/test/java/org/eclipse/openvsx/extension_control/ExtensionControlServiceTest.java b/server/src/test/java/org/eclipse/openvsx/extension_control/ExtensionControlServiceTest.java
new file mode 100644
index 000000000..1ad14e4e5
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/extension_control/ExtensionControlServiceTest.java
@@ -0,0 +1,161 @@
+/********************************************************************************
+ * Copyright (c) 2026 Eclipse Foundation and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+package org.eclipse.openvsx.extension_control;
+
+import jakarta.persistence.EntityManager;
+import org.jobrunr.scheduling.JobRequestScheduler;
+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.eclipse.openvsx.cache.CacheService;
+import org.eclipse.openvsx.entities.Extension;
+import org.eclipse.openvsx.entities.Namespace;
+import org.eclipse.openvsx.repositories.RepositoryService;
+import org.eclipse.openvsx.search.SearchUtilService;
+import org.eclipse.openvsx.util.ExtensionId;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link ExtensionControlService#updateExtension}, focusing on how the replacement is
+ * resolved (never pointing at a missing/inactive extension) and on cache/search invalidation when the
+ * replacement changes.
+ */
+@ExtendWith(MockitoExtension.class)
+class ExtensionControlServiceTest {
+
+ private static final String NAMESPACE = "n";
+
+ @Mock
+ JobRequestScheduler scheduler;
+
+ @Mock
+ RepositoryService repositories;
+
+ @Mock
+ EntityManager entityManager;
+
+ @Mock
+ SearchUtilService search;
+
+ @Mock
+ CacheService cache;
+
+ @InjectMocks
+ ExtensionControlService service;
+
+ private long idSequence = 0;
+
+ private Extension extension(String name, boolean deprecated, boolean active) {
+ var namespace = new Namespace();
+ namespace.setName(NAMESPACE);
+ var extension = new Extension();
+ extension.setId(++idSequence);
+ extension.setName(name);
+ extension.setNamespace(namespace);
+ extension.setDeprecated(deprecated);
+ extension.setActive(active);
+ return extension;
+ }
+
+ private void mockExtension(Extension extension) {
+ when(repositories.findExtension(extension.getName(), NAMESPACE)).thenReturn(extension);
+ }
+
+ private void verifyCachesEvicted(Extension extension) {
+ verify(cache).evictNamespaceDetails(extension);
+ verify(cache).evictLatestExtensionVersion(extension);
+ verify(cache).evictExtensionJsons(extension);
+ verify(search).updateSearchEntry(extension);
+ }
+
+ @Test
+ void doesNotPointAtInactiveReplacement() {
+ var extension = extension("ext", true, true);
+ var replacement = extension("replacement", false, false); // inactive
+ mockExtension(extension);
+ mockExtension(replacement);
+
+ service.updateExtension(
+ new ExtensionId(NAMESPACE, "ext"),
+ true,
+ new ExtensionId(NAMESPACE, "replacement"),
+ true);
+
+ assertThat(extension.getReplacement())
+ .as("an inactive replacement must not be set")
+ .isNull();
+ }
+
+ @Test
+ void pointsAtActiveReplacement() {
+ var extension = extension("ext", true, true);
+ var replacement = extension("replacement", false, true); // active
+ mockExtension(extension);
+ mockExtension(replacement);
+
+ service.updateExtension(
+ new ExtensionId(NAMESPACE, "ext"),
+ true,
+ new ExtensionId(NAMESPACE, "replacement"),
+ true);
+
+ assertThat(extension.getReplacement()).isSameAs(replacement);
+ // The replacement changed (null -> replacement) so caches must be evicted even though the
+ // deprecated flag did not change.
+ verifyCachesEvicted(extension);
+ }
+
+ @Test
+ void evictsCachesWhenReplacementClearedWhileDeprecationUnchanged() {
+ var previousReplacement = extension("old-replacement", false, true);
+ var extension = extension("ext", true, true); // already deprecated
+ extension.setReplacement(previousReplacement);
+ var replacement = extension("replacement", false, false); // now inactive -> must be cleared
+ mockExtension(extension);
+ mockExtension(replacement);
+
+ service.updateExtension(
+ new ExtensionId(NAMESPACE, "ext"),
+ true,
+ new ExtensionId(NAMESPACE, "replacement"),
+ true);
+
+ assertThat(extension.getReplacement())
+ .as("clearing an inactive replacement must null it out")
+ .isNull();
+ verifyCachesEvicted(extension);
+ }
+
+ @Test
+ void doesNotEvictCachesWhenNothingChanged() {
+ var replacement = extension("replacement", false, true);
+ var extension = extension("ext", true, true); // already deprecated
+ extension.setReplacement(replacement);
+ mockExtension(extension);
+ mockExtension(replacement);
+
+ service.updateExtension(
+ new ExtensionId(NAMESPACE, "ext"),
+ true,
+ new ExtensionId(NAMESPACE, "replacement"),
+ true);
+
+ assertThat(extension.getReplacement()).isSameAs(replacement);
+ verify(cache, never()).evictExtensionJsons(extension);
+ verify(search, never()).updateSearchEntry(extension);
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionServiceTest.java b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionServiceTest.java
new file mode 100644
index 000000000..553654b88
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionServiceTest.java
@@ -0,0 +1,115 @@
+/******************************************************************************
+ * 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.publish;
+
+import jakarta.persistence.EntityManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import org.eclipse.openvsx.ExtensionService;
+import org.eclipse.openvsx.entities.Extension;
+import org.eclipse.openvsx.entities.ExtensionVersion;
+import org.eclipse.openvsx.entities.Namespace;
+import org.eclipse.openvsx.repositories.RepositoryService;
+import org.eclipse.openvsx.storage.StorageUtilService;
+import org.eclipse.openvsx.util.TargetPlatform;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link PublishExtensionVersionService#activateExtension}, focusing on the soft-delete
+ * guard: a removed version is an immutable tombstone and must never be reactivated, even though it is
+ * inactive and could otherwise be a reactivation candidate. This can happen when a version is soft-deleted
+ * while an asynchronous scan for it is still in flight and the scan later completes (or is allowed by an
+ * admin) and tries to activate it.
+ */
+@ExtendWith(MockitoExtension.class)
+class PublishExtensionVersionServiceTest {
+
+ @Mock
+ RepositoryService repositories;
+ @Mock
+ EntityManager entityManager;
+ @Mock
+ StorageUtilService storageUtil;
+ @Mock
+ ExtensionService extensions;
+
+ private PublishExtensionVersionService svc;
+
+ @BeforeEach
+ void setUp() {
+ svc = new PublishExtensionVersionService(repositories, entityManager, storageUtil);
+ }
+
+ @Test
+ void activateExtension_activatesLiveVersion() {
+ var extVersion = version(1L, false);
+ when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion);
+
+ svc.activateExtension(extVersion, extensions);
+
+ assertThat(extVersion.isActive()).isTrue();
+ verify(extensions).updateExtension(extVersion.getExtension());
+ }
+
+ @Test
+ void activateExtension_refusesRemovedVersion() {
+ var extVersion = version(1L, true);
+ when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion);
+
+ svc.activateExtension(extVersion, extensions);
+
+ assertThat(extVersion.isActive())
+ .as("a soft-deleted tombstone must never be reactivated")
+ .isFalse();
+ verify(extensions, never()).updateExtension(any());
+ }
+
+ @Test
+ void activateExtension_refusesMissingVersion() {
+ // The row was purged (hard-deleted) between fetch and activation.
+ var extVersion = version(1L, false);
+ when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(null);
+
+ svc.activateExtension(extVersion, extensions);
+
+ assertThat(extVersion.isActive()).isFalse();
+ verify(extensions, never()).updateExtension(any());
+ }
+
+ private ExtensionVersion version(long id, boolean removed) {
+ var namespace = new Namespace();
+ namespace.setName("redhat");
+
+ var extension = new Extension();
+ extension.setName("vscode-yaml");
+ extension.setNamespace(namespace);
+
+ var extVersion = new ExtensionVersion();
+ extVersion.setId(id);
+ extVersion.setVersion("1.0.0");
+ extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL);
+ extVersion.setActive(false);
+ extVersion.setRemoved(removed);
+ extVersion.setExtension(extension);
+ return extVersion;
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.java b/server/src/test/java/org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.java
index 416041da1..2635c3875 100644
--- a/server/src/test/java/org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.java
@@ -27,10 +27,11 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
-import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import redis.clients.jedis.RedisClusterClient;
+import org.eclipse.openvsx.AbstractPostgresContainerTest;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -38,13 +39,11 @@
webEnvironment = WebEnvironment.RANDOM_PORT,
properties = {
"ovsx.rate-limit.enabled=true",
- "ovsx.rate-limit.filters[0].url=/(api|vscode)/.*",
- "ovsx.elasticsearch.enabled=false"
+ "ovsx.rate-limit.filters[0].url=/(api|vscode)/.*"
}
)
@AutoConfigureTestRestTemplate
-@ActiveProfiles("test_db")
-class RateLimitIntegrationTest {
+class RateLimitIntegrationTest extends AbstractPostgresContainerTest {
@LocalServerPort
int port;
diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/NamespaceRepositoryTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/NamespaceRepositoryTest.java
index dd6894296..d7c4d3c00 100644
--- a/server/src/test/java/org/eclipse/openvsx/repositories/NamespaceRepositoryTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/repositories/NamespaceRepositoryTest.java
@@ -18,20 +18,15 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.ActiveProfiles;
+import org.eclipse.openvsx.AbstractPostgresContainerTest;
import org.eclipse.openvsx.entities.Namespace;
import static org.assertj.core.api.Assertions.assertThat;
-@SpringBootTest(
- properties = {
- "ovsx.elasticsearch.enabled=false"
- }
-)
-@ActiveProfiles("test_db")
+@SpringBootTest
@Transactional
-class NamespaceRepositoryTest {
+class NamespaceRepositoryTest extends AbstractPostgresContainerTest {
@Autowired
NamespaceRepository repo;
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 60c5a0dd1..2c2332e33 100644
--- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java
@@ -25,8 +25,8 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
-import org.springframework.test.context.ActiveProfiles;
+import org.eclipse.openvsx.AbstractPostgresContainerTest;
import org.eclipse.openvsx.entities.AdminScanDecision;
import org.eclipse.openvsx.entities.Customer;
import org.eclipse.openvsx.entities.DailyUsageStats;
@@ -56,13 +56,8 @@
* Run the DB queries and assert no DB error, just to ensure that the queries
* are consistent with the schema.
*/
-@SpringBootTest(
- properties = {
- "ovsx.elasticsearch.enabled=false"
- }
-)
-@ActiveProfiles("test_db")
-class RepositoryServiceSmokeTest {
+@SpringBootTest
+class RepositoryServiceSmokeTest extends AbstractPostgresContainerTest {
private static final List
+ *
*/
private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVersion) {
// Look up scan by extension metadata (namespace, name, version, platform)
@@ -284,13 +307,18 @@ private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVers
extVersion.getTargetPlatform());
if (Boolean.TRUE.equals(json.getActive())) {
- // Only mark published if scan result indicates PASSED or no scan result exists (scanning disabled / manual activation)
+ // Only mark published if scan result indicates PASSED or no scan result exists (scanning disabled / manual
+ // activation)
if (scanResult == null || scanResult.getStatus() == ScanStatus.PASSED) {
json.setReviewStatus("published");
return;
}
}
+ if (extVersion.isRemoved()) {
+ return;
+ }
+
if (scanResult == null) {
// No scan result found - show as under review
json.setReviewStatus("under_review");
@@ -358,6 +386,26 @@ private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVers
}
}
+ /**
+ * Returns an extension for the authenticated user's settings view, including every version's
+ * target platforms and, per version, whether the caller may delete it.
+ * public.extension_version.removed.
+ */
+ public final TableFieldpublic.extension_version.removed_timestamp.
+ */
+ public final TableFieldpublic.extension_version.removed_by_id.
+ */
+ public final TableFieldpublic.extension_version.removed.
+ */
+ public void setRemoved(Boolean value) {
+ set(36, value);
+ }
+
+ /**
+ * Getter for public.extension_version.removed.
+ */
+ public Boolean getRemoved() {
+ return (Boolean) get(36);
+ }
+
+ /**
+ * Setter for public.extension_version.removed_timestamp.
+ */
+ public void setRemovedTimestamp(LocalDateTime value) {
+ set(37, value);
+ }
+
+ /**
+ * Getter for public.extension_version.removed_timestamp.
+ */
+ public LocalDateTime getRemovedTimestamp() {
+ return (LocalDateTime) get(37);
+ }
+
+ /**
+ * Setter for public.extension_version.removed_by_id.
+ */
+ public void setRemovedById(Long value) {
+ set(38, value);
+ }
+
+ /**
+ * Getter for public.extension_version.removed_by_id.
+ */
+ public Long getRemovedById() {
+ return (Long) get(38);
+ }
+
// -------------------------------------------------------------------------
// Primary key information
// -------------------------------------------------------------------------
@@ -548,7 +590,7 @@ public ExtensionVersionRecord() {
/**
* Create a detached, initialised ExtensionVersionRecord
*/
- public ExtensionVersionRecord(Long id, String bugs, String description, String displayName, String galleryColor, String galleryTheme, String homepage, String license, String markdown, Boolean preview, String qna, String repository, LocalDateTime timestamp, String version, Long extensionId, Long publishedWithId, Boolean active, String dependencies, String bundledExtensions, String engines, String categories, String tags, String extensionKind, Boolean preRelease, String targetPlatform, String localizedLanguages, String sponsorLink, Long signatureKeyPairId, Integer semverMajor, Integer semverMinor, Integer semverPatch, String semverPreRelease, Boolean semverIsPreRelease, String semverBuildMetadata, Boolean universalTargetPlatform, Boolean potentiallyMalicious) {
+ public ExtensionVersionRecord(Long id, String bugs, String description, String displayName, String galleryColor, String galleryTheme, String homepage, String license, String markdown, Boolean preview, String qna, String repository, LocalDateTime timestamp, String version, Long extensionId, Long publishedWithId, Boolean active, String dependencies, String bundledExtensions, String engines, String categories, String tags, String extensionKind, Boolean preRelease, String targetPlatform, String localizedLanguages, String sponsorLink, Long signatureKeyPairId, Integer semverMajor, Integer semverMinor, Integer semverPatch, String semverPreRelease, Boolean semverIsPreRelease, String semverBuildMetadata, Boolean universalTargetPlatform, Boolean potentiallyMalicious, Boolean removed, LocalDateTime removedTimestamp, Long removedById) {
super(ExtensionVersion.EXTENSION_VERSION);
setId(id);
@@ -587,6 +629,9 @@ public ExtensionVersionRecord(Long id, String bugs, String description, String d
setSemverBuildMetadata(semverBuildMetadata);
setUniversalTargetPlatform(universalTargetPlatform);
setPotentiallyMalicious(potentiallyMalicious);
+ setRemoved(removed);
+ setRemovedTimestamp(removedTimestamp);
+ setRemovedById(removedById);
resetChangedOnNotNull();
}
}
diff --git a/server/src/main/resources/db/migration/V1_70__ExtensionVersion_Removed.sql b/server/src/main/resources/db/migration/V1_70__ExtensionVersion_Removed.sql
new file mode 100644
index 000000000..6ab60b838
--- /dev/null
+++ b/server/src/main/resources/db/migration/V1_70__ExtensionVersion_Removed.sql
@@ -0,0 +1,12 @@
+-- Extension versions are immutable: a "deleted" version is soft-deleted (marked removed) instead of
+-- being physically removed, so its identity stays reserved and can never be republished.
+ALTER TABLE public.extension_version ADD COLUMN removed BOOLEAN;
+ALTER TABLE public.extension_version ADD COLUMN removed_timestamp TIMESTAMP;
+ALTER TABLE public.extension_version ADD COLUMN removed_by_id BIGINT;
+
+UPDATE public.extension_version SET removed = FALSE;
+
+ALTER TABLE public.extension_version ALTER COLUMN removed SET NOT NULL;
+
+ALTER TABLE public.extension_version ADD CONSTRAINT extension_version_removed_by_id_fkey
+FOREIGN KEY (removed_by_id) REFERENCES public.user_data(id);
diff --git a/server/src/test/java/org/eclipse/openvsx/AbstractPostgresContainerTest.java b/server/src/test/java/org/eclipse/openvsx/AbstractPostgresContainerTest.java
new file mode 100644
index 000000000..a030feeda
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/AbstractPostgresContainerTest.java
@@ -0,0 +1,49 @@
+/******************************************************************************
+ * 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;
+
+import org.junit.jupiter.api.Tag;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.postgresql.PostgreSQLContainer;
+
+/**
+ * Base class for tests that need a PostgreSQL database.
+ *