diff --git a/cdc-service/src/main/java/com/xtrmetl/cdc/service/ReplicationSlotProbe.java b/cdc-service/src/main/java/com/xtrmetl/cdc/service/ReplicationSlotProbe.java index aeaf28da..93799163 100644 --- a/cdc-service/src/main/java/com/xtrmetl/cdc/service/ReplicationSlotProbe.java +++ b/cdc-service/src/main/java/com/xtrmetl/cdc/service/ReplicationSlotProbe.java @@ -45,15 +45,31 @@ ELSE pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) private final JdbcTemplate jdbcTemplate; + /** + * Creates an operator probe backed by the primary PostgreSQL datasource. + * + * @param jdbcTemplate datasource client used to read replication-slot state + */ public ReplicationSlotProbe(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } + /** + * Reads the slot selected by {@code CDC_SLOT_NAME}, or the default slot when unset. + * + * @return finite operator status that never exposes database diagnostics + */ public Map probeConfiguredSlot() { String slotName = EnvUtils.getEnv("CDC_SLOT_NAME", "xtrmetl_slot"); return probeSlot(slotName); } + /** + * Reads one logical replication slot and converts database failures into stable status data. + * + * @param slotName PostgreSQL replication-slot identifier to inspect + * @return slot state, not-found state, or a confidential fail-open classification + */ public Map probeSlot(String slotName) { Map result = new LinkedHashMap<>(); result.put("slotName", slotName); @@ -78,11 +94,12 @@ public Map probeSlot(String slotName) { result.put("flushLagBytes", toLong(row.get("flush_lag_bytes"))); return result; } catch (DataAccessException e) { - log.debug("Replication slot probe failed for {}: {}", slotName, e.toString()); + log.debug("Replication slot probe query failed with classification {}", + e.getClass().getSimpleName()); result.put("available", false); result.put("found", false); result.put("error", "query_failed"); - result.put("message", safeMessage(e)); + result.put("message", "Replication slot state unavailable"); return result; } } @@ -101,15 +118,4 @@ private static Long toLong(@Nullable Object value) { return null; } } - - private static String safeMessage(DataAccessException e) { - String msg = e.getMostSpecificCause() != null - ? e.getMostSpecificCause().getMessage() - : e.getMessage(); - if (msg == null || msg.isBlank()) { - return e.getClass().getSimpleName(); - } - // Avoid leaking connection strings if drivers embed them. - return msg.length() > 200 ? msg.substring(0, 200) + "…" : msg; - } } diff --git a/cdc-service/src/test/java/com/xtrmetl/cdc/service/ReplicationSlotProbeConfidentialityTest.java b/cdc-service/src/test/java/com/xtrmetl/cdc/service/ReplicationSlotProbeConfidentialityTest.java new file mode 100644 index 00000000..bff321fc --- /dev/null +++ b/cdc-service/src/test/java/com/xtrmetl/cdc/service/ReplicationSlotProbeConfidentialityTest.java @@ -0,0 +1,61 @@ +package com.xtrmetl.cdc.service; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that operator replication-slot failures keep database diagnostics confidential. + */ +@ExtendWith(OutputCaptureExtension.class) +class ReplicationSlotProbeConfidentialityTest { + + @Test + void failureResponseAndOrdinaryLogsExcludeDriverConnectionDiagnostics(CapturedOutput output) { + Logger probeLogger = assertInstanceOf( + Logger.class, + LoggerFactory.getLogger(ReplicationSlotProbe.class) + ); + Level previous = probeLogger.getLevel(); + // Classification is emitted at DEBUG; INFO-only capture would make secrecy asserts vacuous. + probeLogger.setLevel(Level.DEBUG); + try { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + String sensitiveDriverMessage = + "jdbc:postgresql://db.internal:5432/prod?user=admin&password=super-secret"; + when(jdbc.queryForList(anyString(), eq("xtrmetl_slot"))) + .thenThrow(new DataAccessResourceFailureException(sensitiveDriverMessage)); + + Map result = new ReplicationSlotProbe(jdbc).probeSlot("xtrmetl_slot"); + + assertFalse((Boolean) result.get("available")); + assertEquals("query_failed", result.get("error")); + assertEquals("Replication slot state unavailable", result.get("message")); + assertFalse(result.toString().contains("super-secret")); + assertFalse(result.toString().contains("jdbc:postgresql://")); + String logs = output.getOut() + output.getErr(); + assertTrue(logs.contains("DataAccessResourceFailureException")); + assertFalse(logs.contains("super-secret")); + assertFalse(logs.contains("jdbc:postgresql://")); + } finally { + probeLogger.setLevel(previous); + } + } +}