diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index b456c46c059..ecb34362ab9 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -46,6 +46,7 @@ import com.dotcms.variant.model.Variant; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.business.DotStateException; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.common.reindex.BulkProcessorListener; import com.dotmarketing.common.reindex.ReindexEntry; @@ -1668,33 +1669,208 @@ private void logSwitchover(final IndiciesInfo oldInfo, final String luckyServer) } + /** + * When {@code true}, bypasses the guard that blocks deletion of an active + * (working/live) or building (reindex) index. Off by default: deleting an in-use + * index leaves the site with nothing to serve reads from or reindex into. Intended + * only for emergency/scripted maintenance. + */ + public static final String FF_ALLOW_ACTIVE_INDEX_DELETE = "FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE"; + + /** + * Controls whether {@link #delete(String)} cascades to the index's twin in the other engine. + * On by default. The cascade is bidirectional: deleting a logical index by + * either its ES (bare) name or its OpenSearch ({@code .os}) name removes the index in every + * engine that holds it in the current phase (ES + OS twin during dual-write). + * + */ + public static final String FF_INDEX_DELETE_CASCADE = "FEATURE_FLAG_INDEX_DELETE_CASCADE"; + + /** + * Rejects deletion of an index that is currently active (working/live) or being + * rebuilt (a reindex slot). The protected set is resolved phase-aware via + * {@link #getCurrentIndex()} and {@link #getNewIndex()} — the same sources the + * maintenance UI uses to flag an index active/building — so this + * server-side guard mirrors exactly what the UI already hides. The UI hid the Delete + * option for these indices; a direct REST/AJAX call previously bypassed that and could + * wipe the only index (see issue #35640, TC-018). + * + *

Bypass with the {@value #FF_ALLOW_ACTIVE_INDEX_DELETE} feature flag.

+ * + * @throws DotStateException if the index is active/building (bypass off), or if the + * active set cannot be resolved (fail closed — a destructive + * op must not proceed on an unknown state). + */ + private void assertIndexIsDeletable(final String indexName) { + if (Config.getBooleanProperty(FF_ALLOW_ACTIVE_INDEX_DELETE, false)) { + return; + } + // Compare on the LOGICAL (cluster-stripped, untagged) name on BOTH sides. The active + // set is bare in Phases 0-2 and .os-tagged in Phase 3, and the caller may pass either + // the bare or the .os name — normalizing both to the logical form makes the guard match + // regardless of phase or which twin's name was given (issue #35640). + final String requested = IndexTag.OS.untag(indexAPI.removeClusterIdFromName(indexName)); + final Set protectedIndices = new HashSet<>(); + try { + for (final String n : getCurrentIndex()) { // active working + live + protectedIndices.add(IndexTag.OS.untag(n)); + } + for (final String n : getNewIndex()) { // building reindex slots + protectedIndices.add(IndexTag.OS.untag(n)); + } + } catch (final DotDataException e) { + throw new DotStateException("Unable to verify whether index '" + indexName + + "' is active before deletion; refusing to delete.", e); + } + if (protectedIndices.contains(requested)) { + throw new DotStateException("Index '" + indexName + + "' is active or being rebuilt and cannot be deleted. Deactivate it first" + + " (or set " + FF_ALLOW_ACTIVE_INDEX_DELETE + "=true to override)."); + } + } + public boolean delete(String indexName) { - // Mirror createContentIndex: resolve the per-provider physical name (ES → bare, - // OS → .os tag) via ops.toPhysicalName so a router-driven delete targets the REAL - // OS index. Routing the bare logical name straight to OSIndexAPIImpl.delete would - // hit an untagged name that does not exist and orphan the actual .os index. - // Track the primary provider's result; shadow failures in dual-write phases are - // fire-and-forget (the primary ES delete must not be undone by an OS shadow miss). - final ContentletIndexOperations primary = router.readProvider(); + // Guard first: never delete an active/building index (issue #35640, TC-018). + assertIndexIsDeletable(indexName); + + // Bidirectional cascade (default): deleting by either the ES (bare) or OS (.os) name + // removes the index in every engine that holds it. When disabled, only the engine that + // owns the given name is touched. See FF_INDEX_DELETE_CASCADE (issue #35640, TC-016). + final boolean cascade = Config.getBooleanProperty(FF_INDEX_DELETE_CASCADE, true); + final ContentletIndexOperations primary; + final List targets; + final String nameForTargets; + if (cascade) { + // Broadcast the LOGICAL (untagged) name to every write provider so each re-derives + // its OWN physical name (ES → bare, OS → .os) and both twins are removed regardless + // of which name the caller passed. Track the read provider's result as the primary. + primary = router.readProvider(); + targets = router.writeProviders(); + nameForTargets = IndexTag.OS.untag(indexName); + } else { + // Tag-dispatched, single engine: .os → OS, otherwise ES. The twin is left intact. + primary = IndexTag.OS.isTagged(indexName) ? router.osImpl() : router.esImpl(); + targets = List.of(primary); + nameForTargets = indexName; + } + + // The logical (cluster-stripped, untagged) name is used to find and clear DB pointers. + final String logicalName = IndexTag.OS.untag(indexAPI.removeClusterIdFromName(indexName)); + boolean primaryResult = false; - for (final ContentletIndexOperations ops : router.writeProviders()) { - final String physicalName = ops.toPhysicalName(indexName); + for (final ContentletIndexOperations ops : targets) { + // Resolve the per-provider physical name (ES → bare, OS → .os tag) via + // ops.toPhysicalName so the delete targets the REAL index. Routing a bare + // logical name straight to OSIndexAPIImpl.delete would hit an untagged name + // that does not exist and orphan the actual .os index. + final String physicalName = ops.toPhysicalName(nameForTargets); + // (1) Delete the cluster index. A failure here must NOT abort the operation — + // keep going so the remaining engine and the DB pointers are still handled. try { final boolean r = ops.indexAPI().delete(physicalName); if (ops == primary) { primaryResult = r; } - } catch (Exception e) { + } catch (final Exception e) { Logger.error(this.getClass(), "Error while deleting index " + physicalName, e); if (ops == primary) { primaryResult = false; } // shadow failures are fire-and-forget in dual-write phases } + // (2) Always remove the indicies-table pointer for this engine, even if the cluster + // delete above failed, so no DB row is left dangling at a deleted index. + try { + clearStorePointer(ops, logicalName); + } catch (final Exception e) { + Logger.warn(this.getClass(), + "Could not clear the indicies DB pointer for " + physicalName, e); + } } return primaryResult; } + /** + * Removes any {@code indicies} row that points at {@code logicalName} in the store that backs + * {@code ops}: the OpenSearch provider clears the versioned store, any other provider clears + * the legacy ES store. Best-effort — the caller wraps this so a failure never aborts the + * delete (issue #35640). + */ + private void clearStorePointer(final ContentletIndexOperations ops, final String logicalName) + throws DotDataException { + if (ops == router.osImpl()) { + clearOsStorePointer(logicalName); + } else { + clearEsStorePointer(logicalName); + } + } + + /** + * True when {@code storedName} resolves to the same logical (cluster-stripped, untagged) name + * as {@code logicalName}. Lets a DB slot be matched regardless of the prefix/tag form it was + * stored in. + */ + private boolean matchesLogical(final String storedName, final String logicalName) { + return storedName != null + && logicalName.equals(IndexTag.OS.untag(indexAPI.removeClusterIdFromName(storedName))); + } + + /** Clears any legacy ES-store ({@link IndiciesInfo}) slot pointing at {@code logicalName}. */ + private void clearEsStorePointer(final String logicalName) throws DotDataException { + final IndiciesInfo info = legacyIndiciesAPI.loadIndicies(); + final IndiciesInfo.Builder builder = IndiciesInfo.Builder.copy(info); + boolean changed = false; + if (matchesLogical(info.getWorking(), logicalName)) { builder.setWorking(null); changed = true; } + if (matchesLogical(info.getLive(), logicalName)) { builder.setLive(null); changed = true; } + if (matchesLogical(info.getReindexWorking(), logicalName)) { builder.setReindexWorking(null); changed = true; } + if (matchesLogical(info.getReindexLive(), logicalName)) { builder.setReindexLive(null); changed = true; } + if (matchesLogical(info.getSiteSearch(), logicalName)) { builder.setSiteSearch(null); changed = true; } + if (changed) { + legacyIndiciesAPI.point(builder.build()); + } + } + + /** Clears any OS versioned-store ({@link VersionedIndices}) slot pointing at {@code logicalName}. */ + private void clearOsStorePointer(final String logicalName) throws DotDataException { + final Optional existingOpt = versionedIndicesAPI.loadDefaultVersionedIndices(); + if (existingOpt.isEmpty()) { + return; + } + final VersionedIndices existing = existingOpt.get(); + final VersionedIndicesImpl.Builder builder = VersionedIndicesImpl.builder(); + boolean changed = false; + // Re-add every slot except the one(s) that resolve to the deleted logical name. + if (existing.working().isPresent()) { + if (matchesLogical(existing.working().get(), logicalName)) { changed = true; } + else { builder.working(existing.working().get()); } + } + if (existing.live().isPresent()) { + if (matchesLogical(existing.live().get(), logicalName)) { changed = true; } + else { builder.live(existing.live().get()); } + } + if (existing.reindexWorking().isPresent()) { + if (matchesLogical(existing.reindexWorking().get(), logicalName)) { changed = true; } + else { builder.reindexWorking(existing.reindexWorking().get()); } + } + if (existing.reindexLive().isPresent()) { + if (matchesLogical(existing.reindexLive().get(), logicalName)) { changed = true; } + else { builder.reindexLive(existing.reindexLive().get()); } + } + if (existing.siteSearch().isPresent()) { + if (matchesLogical(existing.siteSearch().get(), logicalName)) { changed = true; } + else { builder.siteSearch(existing.siteSearch().get()); } + } + if (changed) { + versionedIndicesAPI.saveIndices(builder.build()); + } + } + public boolean optimize(List indexNames) { return indexAPI.optimize(indexNames); } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java index 1c88f79a599..567ff16da6e 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java @@ -39,6 +39,7 @@ import com.dotcms.content.elasticsearch.util.ESReindexationProcessStatus; import com.dotcms.contenttype.model.type.ContentType; import com.google.common.annotations.VisibleForTesting; +import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResourceResponse; import com.dotcms.rest.ResponseEntityView; @@ -46,6 +47,7 @@ import com.dotcms.rest.annotation.NoCache; import com.dotcms.rest.api.v1.authentication.ResponseUtil; import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.DotStateException; import com.dotmarketing.business.LayoutAPI; import com.dotmarketing.business.Role; import com.dotmarketing.common.reindex.IndexResourceHelper; @@ -386,13 +388,30 @@ public Response deleteIndex(@Context final HttpServletRequest request, @Context final InitDataObject init = auth(request, response); - if(indexExists(indexName) ){ - return Response.status(404).build(); + // Accept either the short name (as the UI sends it) or the full physical name with the + // cluster prefix (as `_cat/indices` reports it): normalize to the cluster-stripped form + // that listDotCMSIndices()/delete() expect, so a full physical name no longer 404s + // (issue #35640, TC-016). + final String resolvedName = indexAPI.removeClusterIdFromName(indexName); + + if(indexDoesNotExist(resolvedName) ){ + // Readable 404 body (no stack trace) so a mistyped/nonexistent name is clear + // (issue #35640, TC-017). + return Response.status(Response.Status.NOT_FOUND) + .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_FOUND", "Index not found: " + indexName)))).build(); + } + + try { + idxApi.delete(resolvedName); + } catch (final DotStateException e) { + // The index is active/building (or its state could not be verified): reject with a + // readable 400 instead of a stack trace. See issue #35640, TC-018. + Logger.warn(this, "Rejected deletion of index '" + resolvedName + "': " + e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_DELETABLE", e.getMessage())))).build(); } - - idxApi.delete(indexName); - String message = "Index:" + indexName + " deleted"; + String message = "Index:" + resolvedName + " deleted"; sendAdminMessage(message, MessageSeverity.INFO,init.getUser(), 5000); @@ -541,30 +560,37 @@ public Response modIndex(@Context final HttpServletRequest request, @Context fin final InitDataObject init = auth(request, response); final IndexAction indexAction = IndexAction.fromString(action); - - if(indexExists(indexName) ){ - return Response.status(404).build(); + + // Same normalization as deleteIndex: accept both the short name and the full physical + // name (with cluster prefix) so this endpoint stays consistent and does not 404 on the + // latter (issue #35640, TC-016). + final String resolvedName = indexAPI.removeClusterIdFromName(indexName); + + if(indexDoesNotExist(resolvedName) ){ + // Readable 404 body, consistent with deleteIndex (issue #35640, TC-017). + return Response.status(Response.Status.NOT_FOUND) + .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_FOUND", "Index not found: " + indexName)))).build(); } - - + + switch(indexAction){ case DEACTIVATE: - APILocator.getContentletIndexAPI().deactivateIndex(indexName); + APILocator.getContentletIndexAPI().deactivateIndex(resolvedName); break; case CLEAR: - APILocator.getESIndexAPI().clearIndex(indexName); + APILocator.getESIndexAPI().clearIndex(resolvedName); break; case OPEN: - APILocator.getESIndexAPI().openIndex(indexName); + APILocator.getESIndexAPI().openIndex(resolvedName); break; case CLOSE: - APILocator.getESIndexAPI().closeIndex(indexName); + APILocator.getESIndexAPI().closeIndex(resolvedName); break; default: - APILocator.getContentletIndexAPI().activateIndex(indexName); - + APILocator.getContentletIndexAPI().activateIndex(resolvedName); + } - String message = indexAction.name().toLowerCase() + " " + indexName; + String message = indexAction.name().toLowerCase() + " " + resolvedName; sendAdminMessage(message, MessageSeverity.INFO,init.getUser(), 5000); @@ -590,7 +616,12 @@ public Response getIndexStatus(@Context final HttpServletRequest request, } - private boolean indexExists(final String indexName) { + /** + * Returns {@code true} when the given index name is present in neither the open nor the + * closed dotCMS index list — i.e. the index does not exist. Named for the + * caller's guard ({@code if (indexDoesNotExist(...)) return 404}). + */ + private boolean indexDoesNotExist(final String indexName) { return !idxApi.listDotCMSIndices().contains(indexName) && !idxApi.listDotCMSClosedIndices().contains(indexName) ; } } diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java index 7ff153c4561..01f2b89fa8c 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java @@ -444,6 +444,64 @@ public void test_delete_phase1_removesFromBothClusters() throws IOException, Dot Logger.info(this, "✅ delete Phase 1 — both removed: " + DUAL_WORKING); } + /** + * Given Scenario: Phase 1 (dual-write). {@code DUAL_WORKING} exists in both clusters. + * When : cascade is disabled ({@code FEATURE_FLAG_INDEX_DELETE_CASCADE=false}) and + * {@code delete(DUAL_WORKING)} is called with the BARE (ES) name. + * Then : only the ES index is removed; the OS {@code .os} twin is left intact. This is the + * tag-dispatched, no-cascade behavior gated by the feature flag (issue #35640, TC-016). + * Works in single-cluster mode because the OS physical name carries the {@code .os} tag. + */ + @Test + public void test_delete_phase1_cascadeOff_removesOnlyNamedEngine() + throws IOException, DotIndexException { + setPhase(1); + + contentletIndexAPI().createContentIndex(DUAL_WORKING, 1); + assertTrue("Pre: must exist in ES", esImpl().indexExists(DUAL_WORKING)); + assertTrue("Pre: must exist in OS", osIndexAPI.indexExists(physicalDualWorking)); + + Config.setProperty(ContentletIndexAPIImpl.FF_INDEX_DELETE_CASCADE, false); + try { + contentletIndexAPI().delete(DUAL_WORKING); // bare name → tag-dispatched to ES only + } finally { + Config.setProperty(ContentletIndexAPIImpl.FF_INDEX_DELETE_CASCADE, true); + } + + assertFalse("ES index must be gone (it was the named engine)", + esImpl().indexExists(DUAL_WORKING)); + assertTrue("OS .os twin must survive with cascade off", + osIndexAPI.indexExists(physicalDualWorking)); + + Logger.info(this, "✅ delete Phase 1 cascade OFF — only ES removed: " + DUAL_WORKING); + } + + /** + * Given Scenario: Phase 1 (dual-write). {@code DUAL_WORKING} exists in both clusters. + * When : {@code delete()} is called with the {@code .os}-tagged name (as the QA/preview UI + * shows the OS index), with cascade on (default). + * Then : BOTH twins are removed — the cascade is bidirectional, so deleting by the OS name + * also removes the ES twin (the tag is stripped to the logical name and broadcast to + * every provider). Regression for the one-directional-cascade bug (issue #35640). + */ + @Test + public void test_delete_phase1_byOsName_removesFromBothClusters() + throws IOException, DotIndexException { + setPhase(1); + + contentletIndexAPI().createContentIndex(DUAL_WORKING, 1); + assertTrue("Pre: must exist in ES", esImpl().indexExists(DUAL_WORKING)); + assertTrue("Pre: must exist in OS", osIndexAPI.indexExists(physicalDualWorking)); + + contentletIndexAPI().delete(IndexTag.OS.tag(DUAL_WORKING)); // delete by the .os name + + assertFalse("ES twin must be gone when deleting by the .os name (bidirectional cascade)", + esImpl().indexExists(DUAL_WORKING)); + assertFalse("OS .os index must be gone", osIndexAPI.indexExists(physicalDualWorking)); + + Logger.info(this, "✅ delete Phase 1 by .os name — both removed: " + DUAL_WORKING); + } + // ========================================================================= // Orphan bootstrap repair — bare cluster index missing from the store (#36237) // ========================================================================= diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java index b85eff8cbb9..ae723cf7ce4 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java @@ -7,6 +7,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.dotcms.IntegrationTestBase; @@ -62,6 +63,8 @@ import com.dotmarketing.portlets.structure.model.Structure; import com.dotmarketing.portlets.templates.model.Template; import com.dotmarketing.sitesearch.business.SiteSearchAPI; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDGenerator; import com.dotmarketing.util.UtilMethods; @@ -428,6 +431,100 @@ public void activateDeactivateIndex() throws Exception { indexAPI.activateIndex(oldActiveLive); } + /** + * Guard for issue #35640 (TC-018): {@link ContentletIndexAPI#delete(String)} must refuse + * to delete the currently active index (the maintenance UI hides Delete for active indices, + * but a direct REST/AJAX call previously bypassed that and could wipe the only index). The + * refusal is enforced via a {@link DotStateException} and can be overridden with the + * {@code FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE} feature flag. + * + * @see ContentletIndexAPIImpl#delete(String) + */ + @Test + public void delete_activeIndex_isRejected_unlessFeatureFlagOverrides() throws Exception { + + final String timeStamp = String.valueOf(new Date().getTime()); + final String workingIndex = IndexType.WORKING.getPrefix() + "_" + timeStamp; + + final String oldActiveWorking = indexAPI.getActiveIndexName(IndexType.WORKING.getPrefix()); + + assertTrue(indexAPI.createContentIndex(workingIndex)); + indexAPI.activateIndex(workingIndex); + + try { + // The active index is reported by getCurrentIndex(): delete must be rejected and + // the index must survive. + assertThrows(DotStateException.class, () -> indexAPI.delete(workingIndex)); + assertTrue("Active index must survive a rejected delete", + indexAPI.listDotCMSIndices().contains(workingIndex)); + + // Feature-flag bypass: the same delete now goes through. + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, true); + try { + assertTrue(indexAPI.delete(workingIndex)); + assertFalse(indexAPI.listDotCMSIndices().contains(workingIndex)); + } finally { + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, false); + } + } finally { + // Restore the prior active working pointer (best effort). + try { + if (oldActiveWorking != null) { + indexAPI.activateIndex(oldActiveWorking); + } + } catch (final Exception e) { + Logger.warn(this, "Unable to restore active working index after test", e); + } + } + } + + /** + * Issue #35640: deleting an index must also remove its pointer row from the {@code indicies} + * DB table, so no row is left dangling at a deleted index. Uses the feature-flag bypass to + * delete an active index (whose pointer exists in the store) and asserts the pointer is gone. + * + * @see ContentletIndexAPIImpl#delete(String) + */ + @Test + public void delete_clearsDbPointer() throws Exception { + + final String timeStamp = String.valueOf(new Date().getTime()); + final String workingIndex = IndexType.WORKING.getPrefix() + "_" + timeStamp; + + final String oldActiveWorking = indexAPI.getActiveIndexName(IndexType.WORKING.getPrefix()); + + assertTrue(indexAPI.createContentIndex(workingIndex)); + indexAPI.activateIndex(workingIndex); + + // Sanity: the working pointer now references the new index. + final IndiciesInfo before = APILocator.getIndiciesAPI().loadIndicies(); + assertTrue("Pre: working pointer must reference the new index", + before.getWorking() != null && before.getWorking().endsWith(workingIndex)); + + // Delete it, bypassing the active-index guard — the DB pointer must be cleared too. + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, true); + try { + assertTrue(indexAPI.delete(workingIndex)); + } finally { + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, false); + } + + final IndiciesInfo after = APILocator.getIndiciesAPI().loadIndicies(); + assertTrue("Working pointer must no longer reference the deleted index", + after.getWorking() == null || !after.getWorking().endsWith(workingIndex)); + assertFalse("Deleted index must be gone from the cluster", + indexAPI.listDotCMSIndices().contains(workingIndex)); + + // Restore the prior active working pointer (best effort). + try { + if (oldActiveWorking != null) { + indexAPI.activateIndex(oldActiveWorking); + } + } catch (final Exception e) { + Logger.warn(this, "Unable to restore active working index after test", e); + } + } + /** * Testing {@link ContentletIndexAPI#isDotCMSIndexName(String)} *