diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md index 366aeecad2dc..24d12d7dd100 100644 --- a/docs/backend/OPENSEARCH_MIGRATION.md +++ b/docs/backend/OPENSEARCH_MIGRATION.md @@ -353,10 +353,45 @@ closed when a crawl actually runs in a dual-write phase (1 or 2). In the window twin that was never rebuilt, and never crawling, is a hard gap. **Operational rule (pairs with the code):** before promoting the phase — especially into Phase 3 — -ensure every Site Search index has been crawled at least once so its OS twin exists and is in sync. A -targeted verify/repair job (detect twins that are missing **or** whose counts diverge → rebuild full) -that closes the no-crawl window without depending on a crawl is deferred as a follow-up under the same -issue. +ensure every Site Search index has been crawled at least once so its OS counterpart exists and is in +sync. The migration-readiness endpoint below is what tells the operator *which* indices still need +that crawl, before they change the phase. + +#### Migration-readiness endpoint (pre-phase-change advisory) + +`GET /api/v1/index/migration/readiness` is an internal, read-only report a support technician runs +**before changing the migration phase** to see whether it is safe and, if not, what to do. It never +mutates anything — the fix is always the operator re-running the crawl / reindex, which self-heals +through the write-path gate above. + +- **Not public.** The resource is `@Hidden` (absent from the OpenAPI / API-playground schema) and + gated to CMS administrators who **also** hold the migration support role + (`OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY`, default `os_migration_qa`) — a plain admin without the + role is not enough. Anyone else gets a 403, so regular users never learn a migration is running. +- **What it reports.** The current phase with its read/write engines and a `dualWrite` flag; an + overall verdict — `safeToAdvance` (toward OpenSearch-only) and `safeToRollback` (downgrade) with an + `outOfSyncCount`, a human `summary`, and per-index `blockers`; and the per-index ES↔OS mirror diff + for **both** mirrored families — the versioned content indices (`working`/`live`) and the Site + Search indices. `content` is a keyed object by slot (`WORKING` / `LIVE` — a fixed pair); + `siteSearch` is a list (an open set). Each entry carries `{indexName, es:{exists,docCount,physicalName}, + os:{exists,docCount,physicalName}, verdict, recommendation}` — `physicalName` is the full name as + stored on each server (cluster-prefixed; `.os`-tagged on OpenSearch) — with verdict `IN_SYNC` / + `MISSING_COUNTERPART` / `COUNT_DRIFT`. The top level also carries the `clusterId` embedded in every + physical name. The response is the model itself (no `ResponseEntityView` envelope). +- **Stateless, from live counts.** Every field is derived at request time. Counts are **exact** — the + Site Search half uses `SiteSearchAPI.documentCount` and the content half reads each engine leaf's + `getIndicesStats()` (index `_stats` `primaries.docs.count`), never a search total (which the ES/OS + clients cap at 10,000 and would hide drift on large indices). Both reconcilers query the two engine + leaves directly, not the phase-aware router, so the report shows both sides in every phase. +- **`safeToRollback` needs no history.** A downgrade routes reads back to Elasticsearch, so it is + unsafe when any index's ES copy is behind its OpenSearch counterpart (`esDocCount < osDocCount`, or + the ES copy missing) — that delta, typically content written while OpenSearch served reads, would be + silently absent after the downgrade until a full reindex. That is derivable from the same snapshot, + so no per-phase state is persisted. + +Because this endpoint is the source of truth for migration/QA, the index portlets no longer reveal +`.os` indices by role: `MigrationIndexVisibility` is now purely phase-based (hidden in Phases 0/1/2, +shown in Phase 3, for everyone). The role key is retained only to gate this endpoint. #### Tag manipulation is the sole responsibility of `IndexTag` diff --git a/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java b/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java index 1d2672c2b6a1..d806e5a1b353 100644 --- a/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java +++ b/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java @@ -1,12 +1,6 @@ package com.dotcms.content.index; import com.dotcms.content.index.IndexConfigHelper.MigrationPhase; -import com.dotmarketing.business.APILocator; -import com.dotmarketing.business.Role; -import com.dotmarketing.util.Config; -import com.dotmarketing.util.UtilMethods; -import com.liferay.portal.model.User; -import io.vavr.control.Try; import java.util.List; import java.util.stream.Collectors; @@ -21,20 +15,20 @@ * (optimize-all, flush-all, {@code indexExists} validation, bulk fix). Filtering inside those * methods would silently skip OS indices for those operations in phases 1/2 — a behavioural * change disguised as a UI tweak. The complete, phase-correct set must stay intact at the API; - * only the two display sinks (the maintenance JSP and {@code IndexResourceHelper.indexStatsList}) + * only the display sinks (the maintenance JSP and {@code IndexResourceHelper.indexStatsList}) * apply this filter, and only those — see {@code docs/backend/OPENSEARCH_MIGRATION.md}.

* - *

Rule

+ *

Rule — phase-based, for everyone

* * - *

The acting {@link User} is supplied explicitly by each display sink (both are authenticated - * admin requests where the user is always available) — never resolved from a thread-local inside - * this policy, so it is safe to unit-test and free of request-context coupling.

+ *

The role-gated preview of {@code .os} indices was removed in issue #36360: support and QA now + * get migration detail from the dedicated, role-gated migration-readiness endpoint + * ({@code /api/v1/index/migration/readiness}), which is the single source of truth. This display + * policy is therefore purely phase-based and consults no user or role.

* *

OS-origin detection always goes through {@link IndexTag#isTagged(String)}, never * {@code name.endsWith(".os")}, per the {@link IndexTag} contract.

@@ -42,13 +36,14 @@ public final class MigrationIndexVisibility { /** - * Config key holding the {@link Role#getRoleKey() role key} whose members may preview - * OS-tagged ({@code .os}) indices before Phase 3. Defaults to - * {@value #DEFAULT_VISIBILITY_ROLE_KEY}. + * Config key holding the {@link com.dotmarketing.business.Role#getRoleKey() role key} whose + * members may read the role-gated migration-readiness endpoint + * ({@code /api/v1/index/migration/readiness}). Defaults to {@value #DEFAULT_VISIBILITY_ROLE_KEY}. + * It no longer governs the index portlet display (which is purely phase-based since issue #36360). */ public static final String VISIBILITY_ROLE_KEY = "OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY"; - /** Default role key allowed to preview migration ({@code .os}) indices. */ + /** Default role key allowed to read the migration-readiness endpoint. */ public static final String DEFAULT_VISIBILITY_ROLE_KEY = "os_migration_qa"; private MigrationIndexVisibility() { @@ -56,44 +51,28 @@ private MigrationIndexVisibility() { } /** - * Whether {@code user} may see OS-tagged ({@code .os}) indices in the current phase. - * - * @param user the acting user; {@code null} is treated as "not allowed" outside Phase 3 - * @return {@code true} in Phase 3, or when {@code user} holds the configured QA role + * Whether OS-tagged ({@code .os}) indices are shown in the current phase — only in Phase 3, + * where OpenSearch is the live store. Before Phase 3 they are a migration artifact and stay + * hidden from everyone. */ - public static boolean canSeeMigrationIndices(final User user) { - if (MigrationPhase.current().isMigrationComplete()) { - return true; - } - if (user == null) { - return false; - } - final String roleKey = Config.getStringProperty(VISIBILITY_ROLE_KEY, - DEFAULT_VISIBILITY_ROLE_KEY); - if (!UtilMethods.isSet(roleKey)) { - return false; - } - return Try.of(() -> { - final Role role = APILocator.getRoleAPI().loadRoleByKey(roleKey); - return role != null && APILocator.getRoleAPI().doesUserHaveRole(user, role); - }).getOrElse(false); + public static boolean showMigrationIndices() { + return MigrationPhase.current().isMigrationComplete(); } /** - * Returns {@code indexNames} with OS-tagged ({@code .os}) entries removed when {@code user} - * is not allowed to see them; otherwise returns the list unchanged. + * Returns {@code indexNames} with OS-tagged ({@code .os}) entries removed outside Phase 3; + * in Phase 3 (or for a null/empty input) the list is returned unchanged. * - * @param indexNames the full, phase-correct list of index names; {@code null}/empty is - * returned as-is - * @param user the acting user + * @param indexNames the full, phase-correct list of index names; {@code null}/empty is returned + * as-is * @return a filtered copy, or the original list when no filtering applies */ - public static List filter(final List indexNames, final User user) { - if (indexNames == null || indexNames.isEmpty() || canSeeMigrationIndices(user)) { + public static List filter(final List indexNames) { + if (indexNames == null || indexNames.isEmpty() || showMigrationIndices()) { return indexNames; } return indexNames.stream() .filter(name -> !IndexTag.OS.isTagged(name)) .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java new file mode 100644 index 000000000000..15390b1113d6 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java @@ -0,0 +1,122 @@ +package com.dotcms.content.index.migration; + +import com.dotcms.cdi.CDIUtils; +import com.dotcms.content.elasticsearch.business.ESIndexAPI; +import com.dotcms.content.elasticsearch.business.IndiciesInfo; +import com.dotcms.content.index.IndexAPI; +import com.dotcms.content.index.IndexTag; +import com.dotcms.content.index.domain.IndexStats; +import com.dotcms.content.index.migration.MirrorStatus.IndexKind; +import com.dotcms.content.index.migration.MirrorStatus.Verdict; +import com.dotcms.content.index.opensearch.OSIndexAPIImpl; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.google.common.annotations.VisibleForTesting; +import io.vavr.control.Try; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Content-index half of the migration-readiness report (issue #36360): compares the active versioned + * content indices (working and live) against their {@code .os} counterparts across both engines, + * mirroring {@link SiteSearchMirrorReconciler} but for the content store. Never mutates anything. + * + *

How the counts are read (phase-independently)

+ *

{@code IndiciesInfo} always holds the cluster-prefixed, un-tagged Elasticsearch name for + * working/live (its backing {@code indicies} table owns only the ES rows — {@code index_version IS + * NULL}); the OpenSearch counterpart is that name with the {@code .os} tag. Exact per-engine document + * counts come from each engine leaf's {@code getIndicesStats()} — the index {@code _stats} + * {@code primaries.docs.count}, an exact total not subject to the 10,000 search hit-count cap. Those + * stats maps are keyed by the cluster-stripped name (Elasticsearch un-tagged, OpenSearch + * carrying {@code .os}), so each raw name is stripped of the cluster prefix and then, for the + * OpenSearch lookup, tagged — the same strip-then-tag order the maintenance JSP uses.

+ * + *

It queries the two engine leaves directly (never the phase-aware router) so the report shows both + * sides regardless of which engine the current phase reads from. Scope is the active working/live + * pair; reindex slots are out of scope for this report.

+ */ +public class ContentIndexMirrorReconciler { + + private final IndexAPI esImpl; + private final IndexAPI osImpl; + private final Supplier indiciesSupplier; + + public ContentIndexMirrorReconciler() { + this(new ESIndexAPI(), CDIUtils.getBeanThrows(OSIndexAPIImpl.class), + ContentIndexMirrorReconciler::loadIndiciesQuietly); + } + + @VisibleForTesting + ContentIndexMirrorReconciler(final IndexAPI esImpl, final IndexAPI osImpl, + final Supplier indiciesSupplier) { + this.esImpl = esImpl; + this.osImpl = osImpl; + this.indiciesSupplier = indiciesSupplier; + } + + /** Per-index mirror status for the active working and live content indices. */ + public List statuses() { + final IndiciesInfo info = indiciesSupplier.get(); + if (info == null) { + return List.of(); + } + final Map esStats = esImpl.getIndicesStats(); + final Map osStats = osImpl.getIndicesStats(); + final List out = new ArrayList<>(2); + addStatus(out, IndexKind.CONTENT_WORKING, info.getWorking(), esStats, osStats); + addStatus(out, IndexKind.CONTENT_LIVE, info.getLive(), esStats, osStats); + return out; + } + + private void addStatus(final List out, final IndexKind kind, final String rawName, + final Map esStats, final Map osStats) { + if (!UtilMethods.isSet(rawName)) { + return; + } + // IndiciesInfo holds the cluster-prefixed, un-tagged ES name — which IS the full ES physical + // name; the OS physical name is that + .os. The stats maps are keyed by the cluster-stripped + // name (ES un-tagged, OS carrying .os), so strip for the count lookup, tag for the OS key. + final String esPhysical = rawName; + final String osPhysical = IndexTag.OS.tag(rawName); + final String bare = esImpl.removeClusterIdFromName(rawName); + final String osKey = IndexTag.OS.tag(bare); + + final boolean esExists = esStats.containsKey(bare); + final long esCount = esExists ? esStats.get(bare).documentCount() : 0L; + final boolean osExists = osStats.containsKey(osKey); + final long osCount = osExists ? osStats.get(osKey).documentCount() : 0L; + + final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount); + out.add(new MirrorStatus(bare, kind, + new MirrorStatus.EngineCopy(esExists, esCount, esPhysical), + new MirrorStatus.EngineCopy(osExists, osCount, osPhysical), + verdict, recommend(bare, verdict, osExists))); + } + + private static String recommend(final String name, final Verdict verdict, final boolean osExists) { + switch (verdict) { + case IN_SYNC: + return "In sync — no action needed."; + case MISSING_COUNTERPART: + final String missing = osExists ? "Elasticsearch" : "OpenSearch"; + return String.format("The %s copy of content index '%s' is missing. Run a full " + + "reindex to rebuild it before promoting to the OpenSearch-only phase.", + missing, name); + case COUNT_DRIFT: + default: + return String.format("The two copies of content index '%s' hold a different number " + + "of documents. Run a full reindex to rebuild the OpenSearch copy before " + + "promoting the phase.", name); + } + } + + private static IndiciesInfo loadIndiciesQuietly() { + return Try.of(() -> APILocator.getIndiciesAPI().loadIndicies()) + .onFailure(e -> Logger.warn(ContentIndexMirrorReconciler.class, + "Could not load content indices for migration readiness: " + e.getMessage())) + .getOrNull(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadiness.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadiness.java new file mode 100644 index 000000000000..9dae7f4092c8 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadiness.java @@ -0,0 +1,60 @@ +package com.dotcms.content.index.migration; + +import java.util.List; +import java.util.Map; + +/** + * Support-facing ES→OS migration-readiness report (issue #36360): a point-in-time snapshot that a + * role-gated support technician reads before changing the migration phase, to see whether it + * is safe and, if not, what to do. Read-only and stateless — every field is derived from live index + * state at request time, nothing is persisted. + * + * @param clusterId the dotCMS cluster id embedded in every physical index name + * (the {@code } of the {@code cluster_.} prefix); identical for the + * Elasticsearch and OpenSearch backends + * @param phase the current migration phase and which engine it reads/writes + * @param content the versioned content indices keyed by slot ({@code WORKING} / {@code LIVE}) — a + * fixed pair, so a keyed object reads naturally + * @param siteSearch the Site Search indices as a list — an open set with no natural key, so a list + * (each entry carries its own {@code indexName}) + * @param verdict the overall go/no-go for advancing and rolling back, with reasons + */ +public record MigrationReadiness( + String clusterId, + PhaseInfo phase, + Map content, + List siteSearch, + Verdict verdict) { + + /** + * @param current the current phase ordinal (0–3) + * @param name the phase enum name (e.g. {@code PHASE_2_DUAL_WRITE_OS_READS}) + * @param readEngine which engine currently serves reads ("Elasticsearch" or "OpenSearch") + * @param writeEngines which engines currently receive writes + * @param dualWrite whether this is a dual-write phase (1/2), where both engines are populated so a + * cross-engine mirror comparison is meaningful as a forward go/no-go; when false + * (phases 0 and 3) the mirror lists are advisory context, not a forward go/no-go + */ + public record PhaseInfo( + int current, + String name, + String readEngine, + List writeEngines, + boolean dualWrite) {} + + /** + * @param safeToAdvance whether it is safe to promote toward the OpenSearch-only phase + * @param safeToRollback whether it is safe to downgrade — false when any index's Elasticsearch + * copy is behind its OpenSearch counterpart, because a downgrade routes reads back + * to Elasticsearch and would silently drop that delta until a reindex + * @param outOfSyncCount how many indices need attention (missing counterpart or count drift) + * @param summary one human-readable sentence describing the overall state + * @param blockers per-index reasons that make advancing unsafe (empty when safe) + */ + public record Verdict( + boolean safeToAdvance, + boolean safeToRollback, + int outOfSyncCount, + String summary, + List blockers) {} +} diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java new file mode 100644 index 000000000000..5323816110f5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java @@ -0,0 +1,186 @@ +package com.dotcms.content.index.migration; + +import com.dotcms.content.index.IndexConfigHelper.MigrationPhase; +import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.content.index.migration.MirrorStatus.IndexKind; +import com.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Composes the ES→OS migration-readiness report (issue #36360): current phase + the per-index mirror + * status of both mirrored index families (content and Site Search) + an overall go/no-go verdict for + * changing the phase. Read-only and stateless — the verdict is derived from live index state at + * request time, nothing is persisted (see {@link MigrationReadiness}). + * + *

Verdict semantics

+ *
    + *
  • Advance (toward OpenSearch-only): meaningful in the dual-write phases (1/2), where it + * is safe only when no index needs attention. In Phase 0 there is nothing to reconcile yet + * (counterparts are built during dual-write) and in Phase 3 there is no further phase — both report + * safe with an explanatory summary.
  • + *
  • Rollback (downgrade): a downgrade ultimately routes reads back to Elasticsearch + * (Phases 0/1), so it is unsafe when any index's ES copy is behind its OpenSearch counterpart — that + * delta (typically content written while OpenSearch served reads) would be silently missing + * until a full reindex. Derived from the same live counts, so no historical state is needed.
  • + *
+ */ +public class MigrationReadinessService { + + private final SiteSearchMirrorReconciler siteSearchReconciler; + private final ContentIndexMirrorReconciler contentReconciler; + private final Supplier clusterIdSupplier; + + public MigrationReadinessService() { + this(new SiteSearchMirrorReconciler(), new ContentIndexMirrorReconciler(), + ClusterFactory::getClusterId); + } + + @VisibleForTesting + MigrationReadinessService(final SiteSearchMirrorReconciler siteSearchReconciler, + final ContentIndexMirrorReconciler contentReconciler, + final Supplier clusterIdSupplier) { + this.siteSearchReconciler = siteSearchReconciler; + this.contentReconciler = contentReconciler; + this.clusterIdSupplier = clusterIdSupplier; + } + + /** Builds the readiness report for the current phase. */ + public MigrationReadiness evaluate() { + final MigrationPhase phase = MigrationPhase.current(); + final List content = new ArrayList<>(contentReconciler.statuses()); + final List siteSearch = new ArrayList<>(siteSearchReconciler.statuses()); + + final List all = new ArrayList<>(content.size() + siteSearch.size()); + all.addAll(content); + all.addAll(siteSearch); + + final List outOfSync = all.stream() + .filter(MirrorStatus::needsAttention) + .collect(Collectors.toList()); + // A downgrade routes reads back to Elasticsearch; any index whose ES copy is missing or behind + // its OpenSearch counterpart would lose that delta after the downgrade (a failed ES count is -1, which + // is < any real OS count → flagged, fail-safe). + final boolean esBehindAnywhere = all.stream() + .anyMatch(s -> !s.es().exists() || s.es().docCount() < s.os().docCount()); + + // Content WORKING/LIVE are mandatory in every pre-OpenSearch-only phase: they are the source + // that gets mirrored to OpenSearch, so a missing slot (pointer unset, or its Elasticsearch copy + // gone) means there is nothing to migrate — a hard no-go, independent of the sync check, which + // would otherwise pass vacuously when there are no active indices at all. Site Search is an open + // set that may legitimately be empty, so it is not required here. + final List missingContent = requiredContentBlockers(content); + + final boolean safeToAdvance; + final String summary; + final List blockers = new ArrayList<>(); + + if (phase.isMigrationComplete()) { + safeToAdvance = true; // no phase beyond 3 + summary = "Phase 3 (OpenSearch only) — the final phase, nothing to advance to. " + + (esBehindAnywhere + ? "WARNING: OpenSearch holds content Elasticsearch does not; a downgrade would " + + "hide it until a full reindex." + : "No index shows Elasticsearch behind OpenSearch; still verify before any " + + "downgrade."); + } else if (phase.isMigrationNotStarted()) { + // Phase 0: OpenSearch counterparts are built later (during dual-write), so their absence is + // expected and NOT a blocker; only the mandatory Elasticsearch content pair is required. + blockers.addAll(missingContent); + safeToAdvance = blockers.isEmpty(); + summary = safeToAdvance + ? "Phase 0 (Elasticsearch only). OpenSearch counterparts are built during the " + + "dual-write phases, so there is nothing to reconcile yet. Safe to advance " + + "to Phase 1." + : String.format("Not safe to advance from Phase 0: %s to resolve first (see the " + + "blockers list). Dual-write needs an active Elasticsearch content index " + + "to mirror from.", plural(blockers.size(), "blocker")); + } else { + // Phases 1/2 (dual-write): require the mandatory content pair AND every mirror in sync. Drop + // out-of-sync rows already reported as missing content (an ES-missing content slot surfaces + // both as a missing-source blocker and as MISSING_COUNTERPART) so it is not reported twice. + blockers.addAll(missingContent); + outOfSync.stream() + .filter(s -> !(isContent(s.kind()) && !s.es().exists())) + .forEach(s -> blockers.add(String.format("%s '%s': %s", s.kind(), s.indexName(), + s.recommendation()))); + safeToAdvance = blockers.isEmpty(); + summary = safeToAdvance + ? "All mirrors are in sync. Safe to advance toward the OpenSearch-only phase." + : String.format("Not safe to advance: %s to resolve first (see the blockers list). " + + "Phase 3 serves reads from OpenSearch only, so every index must be present " + + "and in sync before promoting.", plural(blockers.size(), "blocker")); + } + + final MigrationReadiness.PhaseInfo phaseInfo = new MigrationReadiness.PhaseInfo( + phase.ordinal(), phase.name(), readEngine(phase), writeEngines(phase), + phase.isDualWrite()); + final MigrationReadiness.Verdict verdict = new MigrationReadiness.Verdict( + safeToAdvance, !esBehindAnywhere, outOfSync.size(), summary, blockers); + + // Content is keyed by slot (WORKING/LIVE — a fixed pair, so a keyed object reads naturally); + // Site Search stays a list (an open set with no natural key). LinkedHashMap keeps the + // reconciler's order for a stable response. + final Map contentBySlot = new LinkedHashMap<>(); + for (final MirrorStatus s : content) { + contentBySlot.put(contentSlot(s.kind()), s); + } + return new MigrationReadiness(clusterIdSupplier.get(), phaseInfo, contentBySlot, + siteSearch, verdict); + } + + private static String contentSlot(final IndexKind kind) { + return kind == IndexKind.CONTENT_WORKING ? "WORKING" : "LIVE"; + } + + /** + * Blockers for the mandatory content pair: WORKING and LIVE must each have a set pointer and an + * existing Elasticsearch copy (the migration source). Returns one message per missing/empty slot; + * an empty list means both are present. This is what stops a "no active content indices" state from + * passing the readiness check vacuously (an empty status list would otherwise leave nothing to flag). + */ + private static List requiredContentBlockers(final List content) { + final List out = new ArrayList<>(2); + for (final IndexKind kind : List.of(IndexKind.CONTENT_WORKING, IndexKind.CONTENT_LIVE)) { + final String slot = contentSlot(kind); + final MirrorStatus status = content.stream() + .filter(s -> s.kind() == kind).findFirst().orElse(null); + if (status == null) { + out.add(String.format("No active %s content index — Elasticsearch has no %s index to " + + "migrate. Reindex to (re)create it before changing the phase.", + slot, slot.toLowerCase())); + } else if (!status.es().exists()) { + out.add(String.format("The active %s content index '%s' has no Elasticsearch copy — " + + "reindex to rebuild it before changing the phase.", slot, status.indexName())); + } + } + return out; + } + + private static boolean isContent(final IndexKind kind) { + return kind == IndexKind.CONTENT_WORKING || kind == IndexKind.CONTENT_LIVE; + } + + /** {@code "1 blocker"} / {@code "2 blockers"} — count with a correctly pluralized noun. */ + private static String plural(final int count, final String noun) { + return count + " " + noun + (count == 1 ? "" : "s"); + } + + private static String readEngine(final MigrationPhase phase) { + return phase.isReadEnabled() ? "OpenSearch" : "Elasticsearch"; + } + + private static List writeEngines(final MigrationPhase phase) { + if (phase.isMigrationNotStarted()) { + return List.of("Elasticsearch"); + } + if (phase.isMigrationComplete()) { + return List.of("OpenSearch"); + } + return List.of("Elasticsearch", "OpenSearch"); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java new file mode 100644 index 000000000000..7760a3f5cd18 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java @@ -0,0 +1,78 @@ +package com.dotcms.content.index.migration; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Per-index ES↔OS mirror status for the migration-readiness report (issue #36360): how one logical + * index compares against its counterpart across the two engines. Purely factual — the phase-aware + * "is this a blocker for changing phase" interpretation is layered on top by + * {@link MigrationReadinessService}. + * + *

Shared by every index family that is mirrored during the migration: the versioned content + * indices (working/live) and the Site Search indices. The {@link IndexKind} says which family this + * row belongs to. Each engine's side is a nested {@link EngineCopy} so the report reads as + * {@code es:{exists,docCount}} / {@code os:{exists,docCount}}.

+ * + * @param indexName the logical index name (no {@code .os} tag) + * @param kind which mirrored index family this row belongs to + * @param es the Elasticsearch copy (existence + exact document count) + * @param os the OpenSearch ({@code .os}) copy (existence + exact document count) + * @param verdict the diff verdict between the two copies + * @param recommendation human-readable, action-oriented advice for a support technician + */ +@JsonIgnoreProperties("kind") // internal grouping/label only — the report keys rows by it, never emits it +public record MirrorStatus( + String indexName, + IndexKind kind, + EngineCopy es, + EngineCopy os, + Verdict verdict, + String recommendation) { + + /** Which mirrored index family a status row belongs to. */ + public enum IndexKind { CONTENT_WORKING, CONTENT_LIVE, SITE_SEARCH } + + /** The diff outcome between an index and its counterpart. */ + public enum Verdict { + /** Both copies exist with the same document count. */ + IN_SYNC, + /** The index exists on one engine but its counterpart is missing on the other. */ + MISSING_COUNTERPART, + /** Both copies exist but hold a different number of documents. */ + COUNT_DRIFT + } + + /** + * One engine's copy of the index. + * + * @param exists whether this engine holds the index + * @param docCount exact document count (0 when absent, -1 when the count query failed) + * @param physicalName the full index name as stored on that engine's server — cluster-prefixed and, + * for OpenSearch, {@code .os}-tagged (e.g. {@code cluster_08abc3.live_20260406} + * on ES, {@code cluster_08abc3.live_20260406.os} on OS). Reported whether or not + * the copy exists, so a missing copy shows the name to look for. + */ + public record EngineCopy(boolean exists, long docCount, String physicalName) {} + + /** Whether this index needs operator action (a re-crawl / reindex) before the phase change. */ + public boolean needsAttention() { + return verdict != Verdict.IN_SYNC; + } + + /** + * Classifies a mirror from raw existence + exact counts: a missing copy on either engine is + * {@link Verdict#MISSING_COUNTERPART}; both present with unequal counts is {@link Verdict#COUNT_DRIFT} + * (a failed count is reported as {@code -1}, which compares unequal and so surfaces as drift — + * fail-safe); otherwise {@link Verdict#IN_SYNC}. + */ + public static Verdict verdictFor(final boolean esExists, final boolean osExists, + final long esDocCount, final long osDocCount) { + if (!esExists || !osExists) { + return Verdict.MISSING_COUNTERPART; + } + if (esDocCount != osDocCount) { + return Verdict.COUNT_DRIFT; + } + return Verdict.IN_SYNC; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java new file mode 100644 index 000000000000..d8f7526deaec --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java @@ -0,0 +1,104 @@ +package com.dotcms.content.index.migration; + +import com.dotcms.cdi.CDIUtils; +import com.dotcms.content.index.IndexConfigHelper; +import com.dotcms.content.index.IndexTag; +import com.dotcms.content.index.migration.MirrorStatus.IndexKind; +import com.dotcms.content.index.migration.MirrorStatus.Verdict; +import com.dotcms.enterprise.publishing.sitesearch.ESSiteSearchAPI; +import com.dotcms.enterprise.publishing.sitesearch.OSSiteSearchAPI; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.sitesearch.business.SiteSearchAPI; +import com.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; +import java.util.function.Supplier; + +/** + * Site Search half of the migration-readiness report (issue #36360): compares every logical + * site-search index against its {@code .os} counterpart across both engines and produces a factual + * {@link MirrorStatus} per index — does the ES copy exist, does the OpenSearch counterpart exist, do their + * exact document counts match — plus a re-crawl recommendation. Never mutates anything. + * + *

It queries the two engine leaves directly (ES the plain name, OpenSearch the {@code .os} counterpart) + * rather than the phase-aware router, so the report shows both sides regardless of which + * engine the current phase reads from. Counts come from {@link SiteSearchAPI#documentCount(String)} + * — an exact total, not a search hit-count capped at 10,000 — so content drift on large indices is + * detected (issue #36360).

+ */ +public class SiteSearchMirrorReconciler { + + private final SiteSearchAPI esImpl; + private final SiteSearchAPI osImpl; + private final Supplier clusterPrefixSupplier; + + public SiteSearchMirrorReconciler() { + this(new ESSiteSearchAPI(), CDIUtils.getBeanThrows(OSSiteSearchAPI.class), + () -> APILocator.getESIndexAPI().getClusterPrefix()); + } + + @VisibleForTesting + SiteSearchMirrorReconciler(final SiteSearchAPI esImpl, final SiteSearchAPI osImpl, + final Supplier clusterPrefixSupplier) { + this.esImpl = esImpl; + this.osImpl = osImpl; + this.clusterPrefixSupplier = clusterPrefixSupplier; + } + + /** + * Whether a cross-engine mirror comparison is meaningful for a forward phase change in the + * current phase. Only the dual-write phases (1 and 2) keep both engines populated as write + * providers; Phase 0 (ES only) and Phase 3 (OS only) have a single write engine, so a "missing + * counterpart" there is either expected (0) or unfixable in-phase (3). + */ + public boolean canEvaluate() { + return IndexConfigHelper.MigrationPhase.current().isDualWrite(); + } + + /** + * The per-index mirror status for every logical site-search index that exists on either + * engine (so a counterpart missing on one side still appears). Purely factual and phase-independent. + */ + public List statuses() { + final TreeSet names = new TreeSet<>(esImpl.listIndices()); + names.addAll(osImpl.listIndices()); + final List statuses = new ArrayList<>(names.size()); + for (final String name : names) { + statuses.add(statusFor(name)); + } + return statuses; + } + + private MirrorStatus statusFor(final String name) { + final boolean esExists = esImpl.existsOnAllWriteEngines(name); + final boolean osExists = osImpl.existsOnAllWriteEngines(name); + final long esCount = esExists ? esImpl.documentCount(name) : 0L; + final long osCount = osExists ? osImpl.documentCount(name) : 0L; + // Physical names as stored: ES is the cluster-prefixed logical name; OS is that + the .os tag + // (applied via IndexTag, the sole owner of the marker). + final String esPhysical = clusterPrefixSupplier.get() + name; + final String osPhysical = IndexTag.OS.tag(esPhysical); + final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount); + return new MirrorStatus(name, IndexKind.SITE_SEARCH, + new MirrorStatus.EngineCopy(esExists, esCount, esPhysical), + new MirrorStatus.EngineCopy(osExists, osCount, osPhysical), + verdict, recommend(name, verdict)); + } + + private static String recommend(final String name, final Verdict verdict) { + switch (verdict) { + case IN_SYNC: + return "In sync — no action needed."; + case MISSING_COUNTERPART: + return String.format("A copy of site-search index '%s' is missing on one engine. " + + "Re-crawl it (Site Search → Run now) to rebuild the counterpart before " + + "promoting to the OpenSearch-only phase.", name); + case COUNT_DRIFT: + default: + return String.format("The two copies of site-search index '%s' hold a different " + + "number of documents. Re-crawl it (Site Search → Run now) to rebuild " + + "the counterpart before promoting the phase.", name); + } + } +} 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 35700822adb7..c9bf0b08db87 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 @@ -644,7 +644,7 @@ public Response getIndexStatus(@Context final HttpServletRequest request, final InitDataObject init = auth(request, response); return Response.ok(new ResponseEntityView<>( - IndexResourceHelper.getInstance().indexStatsList(init.getUser()))).build(); + IndexResourceHelper.getInstance().indexStatsList())).build(); } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java new file mode 100644 index 000000000000..27272d6e5c64 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java @@ -0,0 +1,116 @@ +package com.dotcms.rest.api.v1.index; + +import com.dotcms.content.index.MigrationIndexVisibility; +import com.dotcms.content.index.migration.MigrationReadinessService; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.Role; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.UtilMethods; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Hidden; +import io.vavr.control.Try; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.ForbiddenException; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.glassfish.jersey.server.JSONP; + +/** + * Internal, role-gated ES→OS migration-readiness endpoint (issue #36360). It condenses the migration + * status a support technician needs before changing the phase: the current phase and its + * read/write engines, the per-index ES↔OS mirror diff for both mirrored families (content and Site + * Search) with missing-counterpart / count-drift verdicts and re-crawl/reindex recommendations, and an + * overall safe-to-advance / safe-to-rollback verdict. Read-only; it never mutates any index. + * + *

Not public. The class is {@link Hidden} so it never appears in the OpenAPI / + * API-playground schema, and every method requires a backend user who is a CMS administrator + * and a member of the migration support role + * ({@value com.dotcms.content.index.MigrationIndexVisibility#VISIBILITY_ROLE_KEY}, default + * {@value com.dotcms.content.index.MigrationIndexVisibility#DEFAULT_VISIBILITY_ROLE_KEY}) — a plain + * admin without the role is not enough. Anyone else gets a 403, so regular users never learn a + * migration is running.

+ */ +@Path("/v1/index/migration") +@Hidden +public class MigrationReadinessResource { + + private final MigrationReadinessService readinessService; + + public MigrationReadinessResource() { + this(new MigrationReadinessService()); + } + + @VisibleForTesting + MigrationReadinessResource(final MigrationReadinessService readinessService) { + this.readinessService = readinessService; + } + + /** + * Returns the migration-readiness report for the current phase. Requires a CMS administrator who + * also holds the configured migration support role. + */ + @GET + @JSONP + @NoCache + @Hidden + @Path("/readiness") + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + public Response readiness(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) { + + final InitDataObject initData = new WebResource.InitBuilder(request, response) + .requiredBackendUser(true) + .init(); + + final User user = initData.getUser(); + if (!isMigrationSupportUser(user)) { + throw new ForbiddenException( + "Migration readiness is restricted to CMS administrators who also hold the " + + "migration support role."); + } + + // Return the readiness model directly (no ResponseEntityView envelope) — this internal endpoint + // has no use for the errors/messages/pagination/permissions wrapper. + return Response.ok(readinessService.evaluate()).build(); + } + + /** + * Whether {@code user} may read the migration-readiness report: a CMS administrator who + * also holds the configured support role + * ({@link MigrationIndexVisibility#VISIBILITY_ROLE_KEY}, default + * {@link MigrationIndexVisibility#DEFAULT_VISIBILITY_ROLE_KEY}) — both are required. This is an + * internal support tool in every phase; it never opens up to everyone (a plain admin is not + * enough), unlike the phase-based index portlet display. + */ + @VisibleForTesting + static boolean isMigrationSupportUser(final User user) { + if (user == null) { + return false; + } + return Try.of(() -> { + // Must be BOTH a CMS administrator AND a member of the migration support role — a plain + // admin without the role is not enough, and the role without admin is not enough, so a + // regular user never accesses or learns of the migration (issue #36360). + if (!APILocator.getUserAPI().isCMSAdmin(user)) { + return false; + } + final String roleKey = Config.getStringProperty( + MigrationIndexVisibility.VISIBILITY_ROLE_KEY, + MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY); + if (!UtilMethods.isSet(roleKey)) { + return false; + } + final Role role = APILocator.getRoleAPI().loadRoleByKey(roleKey); + return role != null && APILocator.getRoleAPI().doesUserHaveRole(user, role); + }).getOrElse(false); + } +} diff --git a/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java b/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java index 40f8117bdbaa..7c4af7c4e075 100644 --- a/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java +++ b/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java @@ -13,7 +13,6 @@ import com.dotcms.content.index.domain.IndexStats; import com.dotmarketing.business.APILocator; import com.google.common.collect.ImmutableList; -import com.liferay.portal.model.User; import io.vavr.control.Try; @@ -37,15 +36,15 @@ public static IndexResourceHelper getInstance() { - public List> indexStatsList(final User user) { + public List> indexStatsList() { Map clusterHealth = esapi.getClusterHealth(); - // Hide OS-tagged (.os) migration indices from the maintenance dashboard outside Phase 3, - // unless the acting user holds the configured QA/preview role. Operational paths keep the - // full set; only this display sink filters — see MigrationIndexVisibility. - List openIndices=MigrationIndexVisibility.filter(idxApi.listDotCMSIndices(), user); - List closedIndices=MigrationIndexVisibility.filter(idxApi.listDotCMSClosedIndices(), user); + // Hide OS-tagged (.os) migration indices from the maintenance dashboard outside Phase 3 + // (phase-based, for everyone). Operational paths keep the full set; only this display sink + // filters — see MigrationIndexVisibility. + List openIndices=MigrationIndexVisibility.filter(idxApi.listDotCMSIndices()); + List closedIndices=MigrationIndexVisibility.filter(idxApi.listDotCMSClosedIndices()); List currentIdx = Try.of(()->idxApi.getCurrentIndex()).getOrElse(ImmutableList.of()); List newIdx =Try.of(()->idxApi.getNewIndex()).getOrElse(ImmutableList.of()); Map indexInfo = esapi.getIndicesStats(); diff --git a/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/index_stats.jsp b/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/index_stats.jsp index c82bce90ac40..d82b0f902995 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/index_stats.jsp +++ b/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/index_stats.jsp @@ -45,10 +45,11 @@ try { List currentIdx =idxApi.getCurrentIndex(); List newIdx =idxApi.getNewIndex(); -// Hide OS-tagged (.os) migration indices outside Phase 3 unless the user holds the configured -// QA/preview role. Only this display sink filters; operational paths keep the full set. -List indices=MigrationIndexVisibility.filter(idxApi.listDotCMSIndices(), user); -List closedIndices=MigrationIndexVisibility.filter(idxApi.listDotCMSClosedIndices(), user); +// Hide OS-tagged (.os) migration indices outside Phase 3 (phase-based, for everyone). Only this +// display sink filters; operational paths keep the full set. Support/QA see migration detail via the +// role-gated readiness endpoint, not this portlet (issue #36360). +List indices=MigrationIndexVisibility.filter(idxApi.listDotCMSIndices()); +List closedIndices=MigrationIndexVisibility.filter(idxApi.listDotCMSClosedIndices()); Map indexInfo = esapi.getIndicesStats(); SimpleDateFormat dater = new SimpleDateFormat("yyyyMMddHHmmss"); diff --git a/dotCMS/src/test/java/com/dotcms/content/index/MigrationIndexVisibilityTest.java b/dotCMS/src/test/java/com/dotcms/content/index/MigrationIndexVisibilityTest.java index 10b334dc68d6..4c863d1c7d50 100644 --- a/dotCMS/src/test/java/com/dotcms/content/index/MigrationIndexVisibilityTest.java +++ b/dotCMS/src/test/java/com/dotcms/content/index/MigrationIndexVisibilityTest.java @@ -4,31 +4,22 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import com.dotcms.content.index.IndexConfigHelper.MigrationPhase; -import com.dotmarketing.business.APILocator; -import com.dotmarketing.business.Role; -import com.dotmarketing.business.RoleAPI; -import com.dotmarketing.exception.DotDataException; import com.dotmarketing.util.Config; -import com.liferay.portal.model.User; import java.util.Arrays; import java.util.Collections; import java.util.List; import org.junit.After; import org.junit.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; /** * Unit tests for {@link MigrationIndexVisibility}. * - *

Verifies the phase + role visibility rule for OS-tagged ({@code .os}) indices: - * always visible in Phase 3; otherwise hidden unless the acting user holds the - * configured QA/preview role. The policy must fail closed when the role is absent or the - * lookup errors, and must never resolve the user from a thread-local.

+ *

The policy is purely phase-based (issue #36360 removed the role-gated preview — migration + * detail now comes from the role-gated readiness endpoint): OS-tagged ({@code .os}) indices are + * visible to everyone only in Phase 3; before it they are hidden from the display sinks. No + * user, role, or thread-local is consulted.

*/ public class MigrationIndexVisibilityTest { @@ -44,7 +35,6 @@ private static List mixedList() { @After public void clearConfig() { Config.setProperty(MigrationPhase.FLAG_KEY, null); - Config.setProperty(MigrationIndexVisibility.VISIBILITY_ROLE_KEY, null); } private static void setPhase(final int ordinal) { @@ -52,192 +42,65 @@ private static void setPhase(final int ordinal) { } // ========================================================================= - // Phase 3 — OS is the live store, everything is visible to everyone + // Phase 3 — OS is the live store, .os is visible to everyone // ========================================================================= - /** - * Given Scenario: Phase 3 (OS-only), no user supplied. - * Expected Result: canSeeMigrationIndices is true and filter returns the list unchanged - * (the .os entries stay), without consulting the role API. - */ + /** Phase 3: showMigrationIndices is true and filter returns the list unchanged. */ @Test - public void test_phase3_showsAllIncludingOsTagged_evenForNullUser() { + public void test_phase3_showsAllIncludingOsTagged() { setPhase(3); - assertTrue(MigrationIndexVisibility.canSeeMigrationIndices(null)); + assertTrue(MigrationIndexVisibility.showMigrationIndices()); final List list = mixedList(); assertSame("Phase 3 must return the same list instance untouched", - list, MigrationIndexVisibility.filter(list, null)); + list, MigrationIndexVisibility.filter(list)); } // ========================================================================= - // Phases 0/1/2 — .os hidden unless the user holds the QA role + // Phases 0/1/2 — .os hidden from everyone // ========================================================================= - /** - * Given Scenario: Phase 1 (dual-write), no user (e.g. could not be resolved). - * Expected Result: fail closed — OS-tagged entries are stripped, ES entries remain. - */ + /** Phase 0: .os entries are stripped, ES entries remain. */ @Test - public void test_phase1_nullUser_hidesOsTagged() { - setPhase(1); + public void test_phase0_hidesOsTagged() { + setPhase(0); - assertFalse(MigrationIndexVisibility.canSeeMigrationIndices(null)); + assertFalse(MigrationIndexVisibility.showMigrationIndices()); assertEquals(Arrays.asList(ES_OPEN, ES_CLOSED), - MigrationIndexVisibility.filter(mixedList(), null)); + MigrationIndexVisibility.filter(mixedList())); } - /** - * Given Scenario: Phase 1, an admin user who does NOT hold the QA role. - * Expected Result: OS-tagged entries are hidden. - */ + /** Phase 1 (dual-write): .os hidden. */ @Test - public void test_phase1_userWithoutRole_hidesOsTagged() throws DotDataException { + public void test_phase1_hidesOsTagged() { setPhase(1); - final User user = mock(User.class); - final Role role = mock(Role.class); - final RoleAPI roleAPI = mock(RoleAPI.class); - when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) - .thenReturn(role); - when(roleAPI.doesUserHaveRole(user, role)).thenReturn(false); - - try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { - apiLocator.when(APILocator::getRoleAPI).thenReturn(roleAPI); - - assertFalse(MigrationIndexVisibility.canSeeMigrationIndices(user)); - assertEquals(Arrays.asList(ES_OPEN, ES_CLOSED), - MigrationIndexVisibility.filter(mixedList(), user)); - } - } - /** - * Given Scenario: Phase 1, a user who holds the configured QA role. - * Expected Result: the full list (including .os) is returned. - */ - @Test - public void test_phase1_userWithRole_showsAll() throws DotDataException { - setPhase(1); - final User user = mock(User.class); - final Role role = mock(Role.class); - final RoleAPI roleAPI = mock(RoleAPI.class); - when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) - .thenReturn(role); - when(roleAPI.doesUserHaveRole(user, role)).thenReturn(true); - - try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { - apiLocator.when(APILocator::getRoleAPI).thenReturn(roleAPI); - - assertTrue(MigrationIndexVisibility.canSeeMigrationIndices(user)); - assertEquals(mixedList(), MigrationIndexVisibility.filter(mixedList(), user)); - } + assertFalse(MigrationIndexVisibility.showMigrationIndices()); + assertEquals(Arrays.asList(ES_OPEN, ES_CLOSED), + MigrationIndexVisibility.filter(mixedList())); } - /** - * Given Scenario: Phase 2 behaves identically to Phase 1 (still pre-complete). - * Expected Result: .os hidden for a user without the role. - */ + /** Phase 2 behaves identically to Phase 1 (still pre-complete). */ @Test - public void test_phase2_userWithoutRole_hidesOsTagged() throws DotDataException { + public void test_phase2_hidesOsTagged() { setPhase(2); - final User user = mock(User.class); - final RoleAPI roleAPI = mock(RoleAPI.class); - when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) - .thenReturn(mock(Role.class)); - when(roleAPI.doesUserHaveRole(Mockito.eq(user), Mockito.any(Role.class))) - .thenReturn(false); - - try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { - apiLocator.when(APILocator::getRoleAPI).thenReturn(roleAPI); - - assertEquals(Arrays.asList(ES_OPEN, ES_CLOSED), - MigrationIndexVisibility.filter(mixedList(), user)); - } - } - - // ========================================================================= - // Fail-closed behaviour - // ========================================================================= - - /** - * Given Scenario: Phase 1 and the configured QA role does not exist (loadRoleByKey null). - * Expected Result: fail closed — user cannot see .os indices. - */ - @Test - public void test_phase1_roleMissing_failsClosed() throws DotDataException { - setPhase(1); - final User user = mock(User.class); - final RoleAPI roleAPI = mock(RoleAPI.class); - when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) - .thenReturn(null); - - try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { - apiLocator.when(APILocator::getRoleAPI).thenReturn(roleAPI); - - assertFalse(MigrationIndexVisibility.canSeeMigrationIndices(user)); - } - } - /** - * Given Scenario: Phase 1 and the role lookup throws. - * Expected Result: the exception is swallowed and the policy fails closed. - */ - @Test - public void test_phase1_roleLookupThrows_failsClosed() throws DotDataException { - setPhase(1); - final User user = mock(User.class); - final RoleAPI roleAPI = mock(RoleAPI.class); - when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) - .thenThrow(new DotDataException("boom")); - - try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { - apiLocator.when(APILocator::getRoleAPI).thenReturn(roleAPI); - - assertFalse(MigrationIndexVisibility.canSeeMigrationIndices(user)); - } - } - - // ========================================================================= - // Configurable role key - // ========================================================================= - - /** - * Given Scenario: a custom role key is configured via {@code OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY}. - * Expected Result: the policy looks up that key, not the default, when deciding visibility. - */ - @Test - public void test_customRoleKey_isHonored() throws DotDataException { - setPhase(1); - final String customKey = "my_custom_qa_role"; - Config.setProperty(MigrationIndexVisibility.VISIBILITY_ROLE_KEY, customKey); - - final User user = mock(User.class); - final Role role = mock(Role.class); - final RoleAPI roleAPI = mock(RoleAPI.class); - when(roleAPI.loadRoleByKey(customKey)).thenReturn(role); - when(roleAPI.doesUserHaveRole(user, role)).thenReturn(true); - - try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { - apiLocator.when(APILocator::getRoleAPI).thenReturn(roleAPI); - - assertTrue(MigrationIndexVisibility.canSeeMigrationIndices(user)); - } + assertEquals(Arrays.asList(ES_OPEN, ES_CLOSED), + MigrationIndexVisibility.filter(mixedList())); } // ========================================================================= // Null / empty list handling // ========================================================================= - /** - * Given Scenario: filter is called with null or empty input in a hiding phase. - * Expected Result: the input is returned as-is, no NPE, no role lookup. - */ + /** filter with null or empty input in a hiding phase is returned as-is, no NPE. */ @Test public void test_filter_nullOrEmptyList_returnedAsIs() { setPhase(1); - assertSame(null, MigrationIndexVisibility.filter(null, null)); + assertSame(null, MigrationIndexVisibility.filter(null)); final List empty = Collections.emptyList(); - assertSame(empty, MigrationIndexVisibility.filter(empty, null)); + assertSame(empty, MigrationIndexVisibility.filter(empty)); } -} \ No newline at end of file +} diff --git a/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java b/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java new file mode 100644 index 000000000000..30d7f40d7f5d --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java @@ -0,0 +1,141 @@ +package com.dotcms.content.index.migration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.UnitTestBase; +import com.dotcms.content.elasticsearch.business.IndiciesInfo; +import com.dotcms.content.index.IndexAPI; +import com.dotcms.content.index.domain.IndexStats; +import com.dotcms.content.index.migration.MirrorStatus.IndexKind; +import com.dotcms.content.index.migration.MirrorStatus.Verdict; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link ContentIndexMirrorReconciler} — the content (working/live) half of the + * migration-readiness report (issue #36360). Both engine leaves are mocked and the index names are + * fed through an injected {@code IndiciesInfo}, so no live cluster is needed. The mocked ES leaf + * strips a fixed {@code cluster_x.} prefix, matching {@code removeClusterIdFromName}. + */ +public class ContentIndexMirrorReconcilerTest extends UnitTestBase { + + private static final String PREFIX = "cluster_x."; + + private IndexAPI es; + private IndexAPI os; + + @Before + public void setUp() { + es = mock(IndexAPI.class); + os = mock(IndexAPI.class); + when(es.removeClusterIdFromName(anyString())).thenAnswer(inv -> { + final String n = inv.getArgument(0); + return n.startsWith(PREFIX) ? n.substring(PREFIX.length()) : n; + }); + } + + private static IndexStats stats(final long count) { + final IndexStats s = mock(IndexStats.class); + when(s.documentCount()).thenReturn(count); + return s; + } + + private static IndiciesInfo indicies(final String working, final String live) { + return new IndiciesInfo.Builder().setWorking(working).setLive(live).build(); + } + + private ContentIndexMirrorReconciler reconciler(final IndiciesInfo info) { + return new ContentIndexMirrorReconciler(es, os, () -> info); + } + + /** Both content indices present on both engines with equal counts → two IN_SYNC rows. */ + @Test + public void workingAndLive_inSync() { + // Build the stats maps first: nesting stats() (a when()) inside a when().thenReturn(...) would + // trip Mockito's UnfinishedStubbingException. + final Map esStats = Map.of("working_1", stats(100), "live_1", stats(50)); + final Map osStats = Map.of("working_1.os", stats(100), "live_1.os", stats(50)); + when(es.getIndicesStats()).thenReturn(esStats); + when(os.getIndicesStats()).thenReturn(osStats); + + final List statuses = + reconciler(indicies(PREFIX + "working_1", PREFIX + "live_1")).statuses(); + + assertEquals(2, statuses.size()); + final MirrorStatus working = statuses.get(0); + assertEquals(IndexKind.CONTENT_WORKING, working.kind()); + assertEquals("working_1", working.indexName()); // logical: cluster prefix stripped, no .os + assertEquals(Verdict.IN_SYNC, working.verdict()); + assertEquals(100, working.es().docCount()); + assertEquals(100, working.os().docCount()); + // full physical names as stored: ES cluster-prefixed, OS additionally .os-tagged + assertEquals("cluster_x.working_1", working.es().physicalName()); + assertEquals("cluster_x.working_1.os", working.os().physicalName()); + assertEquals(IndexKind.CONTENT_LIVE, statuses.get(1).kind()); + assertEquals(Verdict.IN_SYNC, statuses.get(1).verdict()); + } + + /** The OpenSearch counterpart of the working index is missing → MISSING_COUNTERPART. */ + @Test + public void missingOsCounterpart_onWorking() { + final Map esStats = Map.of("working_1", stats(100), "live_1", stats(50)); + final Map osStats = Map.of("live_1.os", stats(50)); // working_1.os absent + when(es.getIndicesStats()).thenReturn(esStats); + when(os.getIndicesStats()).thenReturn(osStats); + + final List statuses = + reconciler(indicies(PREFIX + "working_1", PREFIX + "live_1")).statuses(); + + final MirrorStatus working = statuses.get(0); + assertEquals(Verdict.MISSING_COUNTERPART, working.verdict()); + assertTrue(working.es().exists()); + assertFalse(working.os().exists()); + assertTrue(working.needsAttention()); + assertTrue(working.recommendation().contains("OpenSearch")); + } + + /** Counts diverge on the live index (exact stats, no cap) → COUNT_DRIFT. */ + @Test + public void countDrift_onLive() { + final Map esStats = Map.of("working_1", stats(100), "live_1", stats(50)); + final Map osStats = Map.of("working_1.os", stats(100), "live_1.os", stats(40)); + when(es.getIndicesStats()).thenReturn(esStats); + when(os.getIndicesStats()).thenReturn(osStats); + + final List statuses = + reconciler(indicies(PREFIX + "working_1", PREFIX + "live_1")).statuses(); + + final MirrorStatus live = statuses.get(1); + assertEquals(IndexKind.CONTENT_LIVE, live.kind()); + assertEquals(Verdict.COUNT_DRIFT, live.verdict()); + assertEquals(50, live.es().docCount()); + assertEquals(40, live.os().docCount()); + } + + /** A null IndiciesInfo (could not be loaded) yields no rows rather than throwing. */ + @Test + public void nullIndicies_emptyList() { + assertTrue(reconciler(null).statuses().isEmpty()); + } + + /** An unset working/live slot is skipped (no row, no NPE). */ + @Test + public void unsetSlot_skipped() { + final Map esStats = Map.of("live_1", stats(50)); + final Map osStats = Map.of("live_1.os", stats(50)); + when(es.getIndicesStats()).thenReturn(esStats); + when(os.getIndicesStats()).thenReturn(osStats); + + final List statuses = reconciler(indicies(null, PREFIX + "live_1")).statuses(); + + assertEquals(1, statuses.size()); + assertEquals(IndexKind.CONTENT_LIVE, statuses.get(0).kind()); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/content/index/migration/MigrationReadinessServiceTest.java b/dotCMS/src/test/java/com/dotcms/content/index/migration/MigrationReadinessServiceTest.java new file mode 100644 index 000000000000..3aeb37aa9569 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/content/index/migration/MigrationReadinessServiceTest.java @@ -0,0 +1,250 @@ +package com.dotcms.content.index.migration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.UnitTestBase; +import com.dotcms.content.index.IndexConfigHelper; +import com.dotcms.content.index.migration.MirrorStatus.IndexKind; +import com.dotcms.content.index.migration.MirrorStatus.Verdict; +import com.dotmarketing.util.Config; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link MigrationReadinessService} — the phase-aware verdict composed from the two + * mirror reconcilers (issue #36360). Both reconcilers are mocked, so no live cluster is needed; the + * phase is driven through {@code Config}. + */ +public class MigrationReadinessServiceTest extends UnitTestBase { + + private static final int PHASE_0 = 0; + private static final int PHASE_1 = 1; + private static final int PHASE_2 = 2; + private static final int PHASE_3 = 3; + + private String previousPhase; + private SiteSearchMirrorReconciler siteSearch; + private ContentIndexMirrorReconciler content; + private MigrationReadinessService service; + + @Before + public void setUp() { + previousPhase = Config.getStringProperty(IndexConfigHelper.MigrationPhase.FLAG_KEY, null); + siteSearch = mock(SiteSearchMirrorReconciler.class); + content = mock(ContentIndexMirrorReconciler.class); + // Default to a healthy WORKING/LIVE pair so the mandatory-content precondition is satisfied; + // tests that exercise missing content override this explicitly. + when(content.statuses()).thenReturn(healthyContentPair()); + service = new MigrationReadinessService(siteSearch, content, () -> "cluster_x"); + } + + @After + public void tearDown() { + Config.setProperty(IndexConfigHelper.MigrationPhase.FLAG_KEY, previousPhase); + } + + private static void setPhase(final int ordinal) { + Config.setProperty(IndexConfigHelper.MigrationPhase.FLAG_KEY, String.valueOf(ordinal)); + } + + private static MirrorStatus ss(final String name, final boolean esExists, final long esCount, + final boolean osExists, final long osCount) { + final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount); + return new MirrorStatus(name, IndexKind.SITE_SEARCH, + new MirrorStatus.EngineCopy(esExists, esCount, "cluster_x." + name), + new MirrorStatus.EngineCopy(osExists, osCount, "cluster_x." + name + ".os"), + verdict, "advice"); + } + + /** Dual-write phase with every mirror in sync → safe to advance, nothing out of sync. */ + @Test + public void dualWrite_allInSync_safeToAdvance() { + setPhase(PHASE_1); + when(siteSearch.statuses()).thenReturn(List.of(ss("a", true, 100, true, 100))); + + final MigrationReadiness r = service.evaluate(); + + assertEquals("cluster_x", r.clusterId()); + assertTrue(r.phase().dualWrite()); + assertEquals("Elasticsearch", r.phase().readEngine()); + assertTrue(r.verdict().safeToAdvance()); + assertEquals(0, r.verdict().outOfSyncCount()); + assertTrue(r.verdict().blockers().isEmpty()); + } + + /** Dual-write with a missing counterpart → NOT safe to advance, one blocker, count reported. */ + @Test + public void dualWrite_missingCounterpart_blocksAdvance() { + setPhase(PHASE_2); + when(siteSearch.statuses()).thenReturn(List.of( + ss("a", true, 100, true, 100), + ss("b", true, 50, false, 0))); // OS counterpart missing + + final MigrationReadiness r = service.evaluate(); + + assertEquals("OpenSearch", r.phase().readEngine()); + assertFalse(r.verdict().safeToAdvance()); + assertEquals(1, r.verdict().outOfSyncCount()); + assertEquals(1, r.verdict().blockers().size()); + assertTrue(r.verdict().blockers().get(0).contains("'b'")); + } + + /** Count drift above 10k is caught (the reconciler feeds exact counts) → blocks advance. */ + @Test + public void dualWrite_countDriftAbove10k_blocksAdvance() { + setPhase(PHASE_2); + when(siteSearch.statuses()).thenReturn(List.of(ss("big", true, 15_000, true, 12_000))); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.verdict().safeToAdvance()); + assertEquals(1, r.verdict().outOfSyncCount()); + } + + /** OpenSearch ahead of Elasticsearch → a downgrade would lose that delta → not safe to rollback. */ + @Test + public void osAheadOfEs_notSafeToRollback() { + setPhase(PHASE_2); + when(siteSearch.statuses()).thenReturn(List.of(ss("a", true, 80, true, 100))); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.verdict().safeToRollback()); + } + + /** Mirrors even → safe to rollback. */ + @Test + public void mirrorsEven_safeToRollback() { + setPhase(PHASE_2); + when(siteSearch.statuses()).thenReturn(List.of(ss("a", true, 100, true, 100))); + + final MigrationReadiness r = service.evaluate(); + + assertTrue(r.verdict().safeToRollback()); + } + + /** Phase 0: not a dual-write phase, but advancing to dual-write is safe. */ + @Test + public void phase0_notDualWrite_safeToAdvance() { + setPhase(PHASE_0); + when(siteSearch.statuses()).thenReturn(List.of()); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.phase().dualWrite()); + assertEquals(List.of("Elasticsearch"), r.phase().writeEngines()); + assertTrue(r.verdict().safeToAdvance()); + } + + /** Phase 3: not a dual-write phase; write engine is OpenSearch only. */ + @Test + public void phase3_notDualWrite_openSearchOnly() { + setPhase(PHASE_3); + when(siteSearch.statuses()).thenReturn(List.of(ss("a", true, 100, true, 100))); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.phase().dualWrite()); + assertEquals("OpenSearch", r.phase().readEngine()); + assertEquals(List.of("OpenSearch"), r.phase().writeEngines()); + } + + /** Content is keyed by slot (WORKING/LIVE); Site Search stays an ordered list. */ + @Test + public void content_keyedBySlot_siteSearchAsList() { + setPhase(PHASE_2); + when(content.statuses()).thenReturn(List.of( + cc(IndexKind.CONTENT_WORKING, "working_1", 10), + cc(IndexKind.CONTENT_LIVE, "live_1", 5))); + when(siteSearch.statuses()).thenReturn(List.of(ss("sitesearch_a", true, 3, true, 3))); + + final MigrationReadiness r = service.evaluate(); + + assertTrue(r.content().containsKey("WORKING")); + assertTrue(r.content().containsKey("LIVE")); + assertEquals("working_1", r.content().get("WORKING").indexName()); + assertEquals(1, r.siteSearch().size()); + assertEquals("sitesearch_a", r.siteSearch().get(0).indexName()); + } + + /** No active content indices at all → must NOT pass vacuously; both mandatory slots are blockers. */ + @Test + public void dualWrite_noContentIndices_blocksAdvance() { + setPhase(PHASE_1); + when(content.statuses()).thenReturn(List.of()); + when(siteSearch.statuses()).thenReturn(List.of(ss("a", true, 100, true, 100))); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.verdict().safeToAdvance()); + assertEquals(2, r.verdict().blockers().size()); + assertTrue(r.verdict().blockers().stream().anyMatch(b -> b.contains("WORKING"))); + assertTrue(r.verdict().blockers().stream().anyMatch(b -> b.contains("LIVE"))); + } + + /** Phase 0 with no source content indices → cannot even start the migration. */ + @Test + public void phase0_noContentIndices_blocksAdvance() { + setPhase(PHASE_0); + when(content.statuses()).thenReturn(List.of()); + when(siteSearch.statuses()).thenReturn(List.of()); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.verdict().safeToAdvance()); + assertEquals(2, r.verdict().blockers().size()); + } + + /** One content slot present, the other missing → single blocker for the missing slot. */ + @Test + public void dualWrite_oneContentSlotMissing_blocksAdvance() { + setPhase(PHASE_2); + when(content.statuses()).thenReturn(List.of(cc(IndexKind.CONTENT_WORKING, "working_1", 10))); + when(siteSearch.statuses()).thenReturn(List.of()); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.verdict().safeToAdvance()); + assertEquals(1, r.verdict().blockers().size()); + assertTrue(r.verdict().blockers().get(0).contains("LIVE")); + } + + /** A content slot whose ES copy is gone is one blocker, not double-reported as MISSING_COUNTERPART. */ + @Test + public void dualWrite_contentEsCopyMissing_singleBlockerNoDuplicate() { + setPhase(PHASE_2); + when(content.statuses()).thenReturn(List.of( + cc(IndexKind.CONTENT_WORKING, "working_1", true, 10, true, 10), + cc(IndexKind.CONTENT_LIVE, "live_1", false, 0, true, 5))); // ES copy gone + when(siteSearch.statuses()).thenReturn(List.of()); + + final MigrationReadiness r = service.evaluate(); + + assertFalse(r.verdict().safeToAdvance()); + assertEquals(1, r.verdict().blockers().size()); + assertTrue(r.verdict().blockers().get(0).contains("no Elasticsearch copy")); + } + + private static List healthyContentPair() { + return List.of(cc(IndexKind.CONTENT_WORKING, "working_1", 10), + cc(IndexKind.CONTENT_LIVE, "live_1", 5)); + } + + private static MirrorStatus cc(final IndexKind kind, final String name, final long count) { + return cc(kind, name, true, count, true, count); + } + + private static MirrorStatus cc(final IndexKind kind, final String name, final boolean esExists, + final long esCount, final boolean osExists, final long osCount) { + return new MirrorStatus(name, kind, + new MirrorStatus.EngineCopy(esExists, esCount, "cluster_x." + name), + new MirrorStatus.EngineCopy(osExists, osCount, "cluster_x." + name + ".os"), + MirrorStatus.verdictFor(esExists, osExists, esCount, osCount), "advice"); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/index/MigrationReadinessResourceTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/index/MigrationReadinessResourceTest.java new file mode 100644 index 000000000000..cc98088c47d5 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/index/MigrationReadinessResourceTest.java @@ -0,0 +1,96 @@ +package com.dotcms.rest.api.v1.index; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.content.index.MigrationIndexVisibility; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.Role; +import com.dotmarketing.business.RoleAPI; +import com.dotmarketing.business.UserAPI; +import com.dotmarketing.exception.DotDataException; +import com.liferay.portal.model.User; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Unit tests for the role gate of {@link MigrationReadinessResource#isMigrationSupportUser(User)} — + * the readiness endpoint is restricted to users who are BOTH a CMS administrator AND a member of the + * migration support role, and fails closed otherwise (issue #36360). A plain admin without the role, + * and the role without admin, are both denied — so regular users never learn a migration is running. + * All access APIs are mocked; no container needed. + */ +public class MigrationReadinessResourceTest { + + /** No authenticated user → denied. */ + @Test + public void nullUser_denied() { + assertFalse(MigrationReadinessResource.isMigrationSupportUser(null)); + } + + /** A CMS administrator who also holds the support role → allowed. */ + @Test + public void cmsAdminWithRole_allowed() throws DotDataException { + final User user = mock(User.class); + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.isCMSAdmin(user)).thenReturn(true); + final Role role = mock(Role.class); + final RoleAPI roleAPI = mock(RoleAPI.class); + when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) + .thenReturn(role); + when(roleAPI.doesUserHaveRole(user, role)).thenReturn(true); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + api.when(APILocator::getRoleAPI).thenReturn(roleAPI); + assertTrue(MigrationReadinessResource.isMigrationSupportUser(user)); + } + } + + /** A CMS administrator WITHOUT the support role → denied (admin alone is not enough). */ + @Test + public void cmsAdminWithoutRole_denied() throws DotDataException { + final User user = mock(User.class); + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.isCMSAdmin(user)).thenReturn(true); + final RoleAPI roleAPI = mock(RoleAPI.class); + when(roleAPI.loadRoleByKey(MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY)) + .thenReturn(mock(Role.class)); + when(roleAPI.doesUserHaveRole(Mockito.eq(user), Mockito.any(Role.class))).thenReturn(false); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + api.when(APILocator::getRoleAPI).thenReturn(roleAPI); + assertFalse(MigrationReadinessResource.isMigrationSupportUser(user)); + } + } + + /** A support-role member who is NOT a CMS administrator → denied (role alone is not enough). */ + @Test + public void roleWithoutAdmin_denied() throws DotDataException { + final User user = mock(User.class); + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.isCMSAdmin(user)).thenReturn(false); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + assertFalse(MigrationReadinessResource.isMigrationSupportUser(user)); + } + } + + /** An access-layer failure fails closed (denied), never open. */ + @Test + public void accessLookupThrows_failsClosed() throws DotDataException { + final User user = mock(User.class); + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.isCMSAdmin(user)).thenThrow(new DotDataException("boom")); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + assertFalse(MigrationReadinessResource.isMigrationSupportUser(user)); + } + } +}