1
0

M7 Logging-Sensitivität mit echten Log- und Persistenznachweisen

abgesichert
This commit is contained in:
2026-04-08 10:52:59 +02:00
parent f2bbc8a884
commit cab9fed5b0
3 changed files with 202 additions and 272 deletions

View File

@@ -753,6 +753,61 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY); assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
} }
// -------------------------------------------------------------------------
// AI field persistence is independent of logging configuration
// -------------------------------------------------------------------------
/**
* Verifies that the repository always stores the complete AI raw response and reasoning,
* independent of any logging sensitivity configuration.
* <p>
* The {@code AiContentSensitivity} setting controls only whether sensitive content is
* written to log files. It has no influence on what the repository persists. This test
* demonstrates that full AI fields are stored regardless of any logging configuration by
* verifying a round-trip with both full content and long reasoning text.
*/
@Test
void save_persistsFullAiResponseAndReasoning_unaffectedByLoggingConfiguration() {
// The repository has no dependency on AiContentSensitivity.
// It always stores the complete AI raw response and reasoning.
DocumentFingerprint fingerprint = new DocumentFingerprint(
"d1d2d3d4d5d6d7d8d9dadbdcdddedfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfd0".substring(0, 64));
RunId runId = new RunId("persistence-independence-run");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Deliberately long and complete AI raw response — must be stored in full
String fullRawResponse = "{\"date\":\"2026-03-01\",\"title\":\"Stromabrechnung\","
+ "\"reasoning\":\"Invoice date clearly stated on page 1. Utility provider named.\"}";
// Deliberately complete reasoning — must be stored in full
String fullReasoning = "Invoice date clearly stated on page 1. Utility provider named.";
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4o", "prompt-v1.txt",
3, 750,
fullRawResponse,
fullReasoning,
LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
"Stromabrechnung",
null
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
// Full raw response is stored completely — not truncated, not suppressed
assertThat(result.aiRawResponse()).isEqualTo(fullRawResponse);
// Full reasoning is stored completely — not truncated, not suppressed
assertThat(result.aiReasoning()).isEqualTo(fullReasoning);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Integration with document records (FK constraints) // Integration with document records (FK constraints)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -2,16 +2,13 @@ package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity; import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.mockito.ArgumentCaptor;
import java.io.IOException; import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** /**
* Unit tests for {@link Log4jProcessingLogger}. * Unit tests for {@link Log4jProcessingLogger}.
@@ -23,6 +20,7 @@ import static org.mockito.Mockito.*;
class Log4jProcessingLoggerTest { class Log4jProcessingLoggerTest {
private Log4jProcessingLogger logger; private Log4jProcessingLogger logger;
private TestLogCapture logCapture;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@@ -30,6 +28,14 @@ class Log4jProcessingLoggerTest {
logger = new Log4jProcessingLogger(Log4jProcessingLoggerTest.class); logger = new Log4jProcessingLogger(Log4jProcessingLoggerTest.class);
} }
@AfterEach
void tearDownCapture() {
if (logCapture != null) {
logCapture.stopCapture();
logCapture = null;
}
}
@Test @Test
void constructor_createsLoggerForClass() { void constructor_createsLoggerForClass() {
// Verify that a logger is successfully created for the given class // Verify that a logger is successfully created for the given class
@@ -240,202 +246,99 @@ class Log4jProcessingLoggerTest {
}, "Constructor should reject null AiContentSensitivity"); }, "Constructor should reject null AiContentSensitivity");
} }
// ===== Behavioral tests: Logging sensitivity enforcement ===== // ===== Behavioral tests: real log capture verification =====
/** /**
* Verifies that debugSensitiveAiContent() respects PROTECT_SENSITIVE_CONTENT. * Verifies that in PROTECT_SENSITIVE_CONTENT mode, the raw AI response is NOT written to any log.
* <p> * Uses {@link TestLogCapture} to prove suppression with real log output.
* This test demonstrates that when PROTECT_SENSITIVE_CONTENT is active,
* the method can be called without error, and the implementation respects
* the protection setting by not emitting log output.
*/ */
@Test @Test
void debugSensitiveAiContent_protectionMode_acceptsCall() { void debugSensitiveAiContent_protectionMode_rawResponseNotLogged() {
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class, Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
String sensitiveContent = "PROTECT_RAW_RESPONSE_9f3e2b1a";
// Act: Call with sensitive content in protection mode logCapture = new TestLogCapture();
String sensitiveContent = "Complete AI response: {\"title\": \"Stromrechnung\"}"; logCapture.startCapture();
assertDoesNotThrow(() -> {
protectedLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent);
}, "Protection mode should accept debugSensitiveAiContent() calls without error");
// The actual protection check happens inside Log4jProcessingLogger.debugSensitiveAiContent():
// it checks: if (aiContentSensitivity == AiContentSensitivity.LOG_SENSITIVE_CONTENT)
// In protection mode, the log4jLogger.debug() is NOT called.
}
/**
* Verifies that debugSensitiveAiContent() respects LOG_SENSITIVE_CONTENT.
* <p>
* This test demonstrates that when LOG_SENSITIVE_CONTENT is explicitly enabled,
* the debugSensitiveAiContent() method works identically and the implementation
* logs the content by delegating to Log4j2 at DEBUG level.
*/
@Test
void debugSensitiveAiContent_explicitOptIn_acceptsCall() {
Log4jProcessingLogger optInLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT);
// Act: Call with sensitive content in opt-in mode
String sensitiveContent = "Complete AI response: {\"title\": \"Stromrechnung\"}";
assertDoesNotThrow(() -> {
optInLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent);
}, "Opt-in mode should accept debugSensitiveAiContent() calls without error");
// The actual logging happens inside Log4jProcessingLogger.debugSensitiveAiContent():
// it checks: if (aiContentSensitivity == AiContentSensitivity.LOG_SENSITIVE_CONTENT)
// In opt-in mode, log4jLogger.debug(message, args) IS called.
}
/**
* Verifies that the reasoning field is also protected by the sensitivity rule.
* <p>
* This test ensures that not only the raw AI response but also the reasoning
* field is subject to the same protection/opt-in behavior.
*/
@Test
void debugSensitiveAiContent_reasoning_respectsSensitivity() {
// Test both modes
AiContentSensitivity[] modes = {
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT,
AiContentSensitivity.LOG_SENSITIVE_CONTENT
};
for (AiContentSensitivity mode : modes) {
// Create logger in the current mode
Log4jProcessingLogger logger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
mode);
// Verify that the method exists and is callable
// The actual sensitivity decision is made inside debugSensitiveAiContent()
assertDoesNotThrow(() -> {
logger.debugSensitiveAiContent("AI reasoning: {}", "reasoning text");
}, "Both sensitivity modes should accept reasoning messages");
}
}
/**
* Verifies that non-sensitive info/debug methods are NOT controlled by AiContentSensitivity.
* <p>
* This test ensures that the sensitivity rule applies ONLY to debugSensitiveAiContent(),
* not to regular debug() or info() methods.
*/
@Test
void regularDebugMethod_notAffectedBySensitivitySetting() {
// Create loggers in both modes
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
Log4jProcessingLogger optInLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT);
// Both should accept regular debug() calls
assertDoesNotThrow(() -> {
protectedLogger.debug("Regular message in protection mode");
optInLogger.debug("Regular message in opt-in mode");
}, "Regular debug() should work regardless of sensitivity setting");
// Both should accept regular info() calls
assertDoesNotThrow(() -> {
protectedLogger.info("Info in protection mode");
optInLogger.info("Info in opt-in mode");
}, "Regular info() should work regardless of sensitivity setting");
}
/**
* Verifies that SQLite persistence is independent of logging sensitivity.
* <p>
* This test documents that the logging sensitivity rule does NOT affect
* whether sensitive content is persisted. Sensitive content remains in SQLite
* regardless of the log.ai.sensitive setting.
* <p>
* Note: This is a documentation test that verifies the interface contract.
* The actual SQLite persistence is verified in integration tests.
*/
@Test
void sensitivityRule_doesNotAffectSqlitePersistence() {
// Verify that both protection and opt-in modes exist
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
Log4jProcessingLogger optInLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT);
// The interface contract states that both loggers must be creatable
assertNotNull(protectedLogger, "Protect mode logger must be creatable");
assertNotNull(optInLogger, "Opt-in mode logger must be creatable");
// Both have the debugSensitiveAiContent method available
// The actual persistence is handled independently in the repository layer
// This test verifies that the logger API is consistent regardless of sensitivity
}
// ===== Behavioral tests with Mockito spy verification =====
/**
* Verifies that in protection mode, debugSensitiveAiContent() does NOT call the underlying logger.
* <p>
* This test uses Mockito to spy on the Log4j2 logger and verify that the debug() method
* is never called in protection mode.
*/
@Test
void debugSensitiveAiContent_protectionMode_doesNotCallLog4jDebug() {
// Setup: Create logger with spied Log4j2 logger
Logger spiedLog4jLogger = spy(LogManager.getLogger(Log4jProcessingLoggerTest.class));
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT) {
// Override to use spied logger
private final Logger log4jLoggerInstance = spiedLog4jLogger;
};
// Act: Call debugSensitiveAiContent with sensitive content
String sensitiveContent = "{\"title\": \"Stromrechnung\"}";
protectedLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent); protectedLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent);
logCapture.stopCapture();
// Assert: The underlying Log4j debug() should never be called in protection mode assertFalse(logCapture.containsMessage(sensitiveContent),
// This verifies the BEHAVIOR: protection mode suppresses the log call "PROTECT_SENSITIVE_CONTENT mode must not write raw AI response to log");
} }
/** /**
* Verifies that in opt-in mode, debugSensitiveAiContent() calls the underlying logger. * Verifies that in LOG_SENSITIVE_CONTENT mode, the raw AI response IS written to log.
* <p> * Uses {@link TestLogCapture} to prove emission with real log output.
* This test uses logic to verify that LOG_SENSITIVE_CONTENT mode allows the debug call.
*/ */
@Test @Test
void debugSensitiveAiContent_optInMode_implementationCallsDebug() { void debugSensitiveAiContent_optInMode_rawResponseLogged() {
// Setup: Create logger in opt-in mode
Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( Log4jProcessingLogger optInLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class, Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT); AiContentSensitivity.LOG_SENSITIVE_CONTENT);
String sensitiveContent = "OPTIN_RAW_RESPONSE_7d4c8f2e";
// Act: Call debugSensitiveAiContent logCapture = new TestLogCapture();
String sensitiveContent = "{\"title\": \"Stromrechnung\"}"; logCapture.startCapture();
// This should not throw - it should call log4jLogger.debug() optInLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent);
assertDoesNotThrow(() -> { logCapture.stopCapture();
optInLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent);
}, "Opt-in mode should allow debug() calls without error");
// Assert: The fact that it didn't throw is implicit verification that assertTrue(logCapture.containsMessage(sensitiveContent),
// LOG_SENSITIVE_CONTENT mode calls the underlying logger correctly "LOG_SENSITIVE_CONTENT mode must write raw AI response to log when explicitly enabled");
} }
/** /**
* Verifies the critical behavioral contrast: protection mode vs. opt-in mode. * Verifies that in PROTECT_SENSITIVE_CONTENT mode, the AI reasoning field is NOT written to log.
* <p> * Uses {@link TestLogCapture} to prove suppression of the reasoning field specifically.
* This test demonstrates that debugSensitiveAiContent() behaves differently
* depending on the AiContentSensitivity setting. With PROTECT, it does nothing.
* With LOG, it delegates to the underlying logger.
*/ */
@Test @Test
void debugSensitiveAiContent_modeContrast_behaviorDiffers() { void debugSensitiveAiContent_protectionMode_reasoningNotLogged() {
// Setup Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
String sensitiveReasoning = "PROTECT_REASONING_c8a1e5f3";
logCapture = new TestLogCapture();
logCapture.startCapture();
protectedLogger.debugSensitiveAiContent("AI reasoning: {}", sensitiveReasoning);
logCapture.stopCapture();
assertFalse(logCapture.containsMessage(sensitiveReasoning),
"PROTECT_SENSITIVE_CONTENT mode must not write AI reasoning to log");
}
/**
* Verifies that in LOG_SENSITIVE_CONTENT mode, the AI reasoning field IS written to log.
* Uses {@link TestLogCapture} to prove emission of the reasoning field specifically.
*/
@Test
void debugSensitiveAiContent_optInMode_reasoningLogged() {
Log4jProcessingLogger optInLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT);
String sensitiveReasoning = "OPTIN_REASONING_b2d6f9a4";
logCapture = new TestLogCapture();
logCapture.startCapture();
optInLogger.debugSensitiveAiContent("AI reasoning: {}", sensitiveReasoning);
logCapture.stopCapture();
assertTrue(logCapture.containsMessage(sensitiveReasoning),
"LOG_SENSITIVE_CONTENT mode must write AI reasoning to log when explicitly enabled");
}
/**
* Verifies the behavioral contrast between protection and opt-in mode using real log capture.
* <p>
* The same sensitive content token is logged in both modes: protection mode must suppress it,
* opt-in mode must emit it. Proves the conditional in {@code debugSensitiveAiContent} works
* in both directions.
*/
@Test
void debugSensitiveAiContent_modeContrast_differentLogOutput() {
String sensitiveContent = "CONTRAST_SENSITIVE_d3e7a1b8";
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class, Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
@@ -443,87 +346,44 @@ class Log4jProcessingLoggerTest {
Log4jProcessingLoggerTest.class, Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT); AiContentSensitivity.LOG_SENSITIVE_CONTENT);
String sensitiveContent = "{\"title\": \"Test\"}"; // Protection mode: content must NOT appear in logs
logCapture = new TestLogCapture();
logCapture.startCapture();
protectedLogger.debugSensitiveAiContent("AI: {}", sensitiveContent);
logCapture.stopCapture();
assertFalse(logCapture.containsMessage(sensitiveContent),
"PROTECT mode must suppress sensitive content");
// Act & Assert: Both should execute without error, but the BEHAVIOR is different // Opt-in mode: same content MUST appear in logs
// In protection mode: log4jLogger.debug() is NOT called logCapture = new TestLogCapture();
// In opt-in mode: log4jLogger.debug() IS called logCapture.startCapture();
assertDoesNotThrow(() -> protectedLogger.debugSensitiveAiContent("AI: {}", sensitiveContent)); optInLogger.debugSensitiveAiContent("AI: {}", sensitiveContent);
assertDoesNotThrow(() -> optInLogger.debugSensitiveAiContent("AI: {}", sensitiveContent)); logCapture.stopCapture();
assertTrue(logCapture.containsMessage(sensitiveContent),
// The behavioral difference is encoded in the conditional inside debugSensitiveAiContent(): "LOG mode must emit sensitive content");
// if (aiContentSensitivity == AiContentSensitivity.LOG_SENSITIVE_CONTENT) {
// log4jLogger.debug(message, args);
// }
} }
/** /**
* Verifies that regular logging methods (debug, info) are NOT affected by sensitivity setting. * Verifies that regular {@code debug()} calls always produce log output,
* even when the logger is configured with PROTECT_SENSITIVE_CONTENT.
* <p> * <p>
* This test ensures that only debugSensitiveAiContent() respects the sensitivity setting. * The sensitivity rule applies only to {@code debugSensitiveAiContent()},
* not to the regular logging methods.
*/ */
@Test @Test
void regularLoggingMethods_notAffectedBySensitivitySetting() { void regularDebugMethod_alwaysLogged_unaffectedBySensitivity() {
// Create logger in protection mode
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class, Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
String regularMarker = "REGULAR_DEBUG_e6f0c2d9";
// Act: Call regular logging methods logCapture = new TestLogCapture();
String message = "Regular message"; logCapture.startCapture();
assertDoesNotThrow(() -> { protectedLogger.debug("Regular: {}", regularMarker);
protectedLogger.info(message); logCapture.stopCapture();
protectedLogger.debug("Debug: {}", message);
}, "Regular logging methods should work regardless of sensitivity setting");
// Assert: The fact that both succeeded shows they're not affected by PROTECT_SENSITIVE_CONTENT assertTrue(logCapture.containsMessage(regularMarker),
} "Regular debug() must always produce log output regardless of sensitivity setting");
/**
* Verifies that debugSensitiveAiContent respects its setting on every call.
* <p>
* This test confirms that the sensitivity decision is consistently applied.
*/
@Test
void debugSensitiveAiContent_sensitivityDecisionPerCall_consistent() {
// Setup: Create a protection-mode logger
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
// Act: Make multiple sensitive log calls
assertDoesNotThrow(() -> {
protectedLogger.debugSensitiveAiContent("First: {}", "sensitive-1");
protectedLogger.debugSensitiveAiContent("Second: {}", "sensitive-2");
protectedLogger.debugSensitiveAiContent("Third: {}", "sensitive-3");
}, "All debugSensitiveAiContent calls should execute without error");
// Assert: The setting is consistently applied across all calls
// (In protection mode, all suppress the actual log output)
}
/**
* Verifies that sensitivity setting does NOT affect SQLite persistence independently.
* <p>
* This test confirms that the logger only controls log file output, not persistence.
* The logger has no knowledge of or control over SQLite persistence.
*/
@Test
void sensitivityRule_doesNotAffectPersistenceDecisions() {
// Both modes should have the same method signature and behavior
Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
Log4jProcessingLogger optInLogger = new Log4jProcessingLogger(
Log4jProcessingLoggerTest.class,
AiContentSensitivity.LOG_SENSITIVE_CONTENT);
// Both have the same interface and should support the same calls
assertDoesNotThrow(() -> protectedLogger.debugSensitiveAiContent("AI: {}", "data"));
assertDoesNotThrow(() -> optInLogger.debugSensitiveAiContent("AI: {}", "data"));
// The logger itself doesn't control persistence - that's in the repository layer.
// This test verifies the logger interface is consistent regardless of sensitivity.
} }
} }

View File

@@ -1,19 +1,24 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter; package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.AbstractAppender; import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Configuration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
/** /**
* Test utility that captures Log4j2 log events during test execution. * Test utility that captures Log4j2 log events during test execution.
* <p> * <p>
* This class temporarily attaches a ListAppender to the root logger * Registers a list appender on the root {@link org.apache.logging.log4j.core.config.LoggerConfig}
* to capture all log messages, allowing behavioral verification of logging * via the Log4j2 {@link Configuration} API so that events from all descendant loggers propagate
* decisions in unit tests. * to the appender correctly. Call {@link #startCapture()} before the code under test and
* {@link #stopCapture()} after it to bracket the capture window.
* <p> * <p>
* Usage: * Usage:
* <pre> * <pre>
@@ -30,34 +35,41 @@ import java.util.Objects;
class TestLogCapture { class TestLogCapture {
private final ListAppender listAppender; private final ListAppender listAppender;
private org.apache.logging.log4j.core.Logger rootLogger;
/** /**
* Creates a new TestLogCapture that will capture all log events. * Creates a new TestLogCapture with a uniquely named internal appender.
*/ */
TestLogCapture() { TestLogCapture() {
this.listAppender = new ListAppender(); // Unique name avoids conflicts when multiple instances are created in one test run
this.listAppender = new ListAppender("TestListAppender-" + UUID.randomUUID());
} }
/** /**
* Starts capturing log events from the root logger. * Starts capturing log events by registering the appender on the root logger configuration.
* <p> * <p>
* Attaches a ListAppender to the root logger to capture all messages. * Uses {@link Configuration#getRootLogger()} and {@link LoggerContext#updateLoggers()} so
* that events from all descendant loggers propagate through the configuration hierarchy to
* the appender.
*/ */
void startCapture() { void startCapture() {
LoggerContext context = (LoggerContext) LogManager.getContext(false); LoggerContext context = (LoggerContext) LogManager.getContext(false);
this.rootLogger = context.getRootLogger(); Configuration config = context.getConfiguration();
this.listAppender.start(); listAppender.start();
this.rootLogger.addAppender(this.listAppender); config.addAppender(listAppender);
config.getRootLogger().addAppender(listAppender, Level.ALL, null);
context.updateLoggers();
} }
/** /**
* Stops capturing log events and removes the appender. * Stops capturing and removes the appender from the root logger configuration.
*/ */
void stopCapture() { void stopCapture() {
if (this.rootLogger != null) { LoggerContext context = (LoggerContext) LogManager.getContext(false);
this.rootLogger.removeAppender(this.listAppender); Configuration config = context.getConfiguration();
this.listAppender.stop(); config.getRootLogger().removeAppender(listAppender.getName());
context.updateLoggers();
if (listAppender.isStarted()) {
listAppender.stop();
} }
} }
@@ -69,7 +81,7 @@ class TestLogCapture {
*/ */
boolean containsMessage(String substring) { boolean containsMessage(String substring) {
Objects.requireNonNull(substring, "substring must not be null"); Objects.requireNonNull(substring, "substring must not be null");
return this.listAppender.getEvents() return listAppender.getEvents()
.stream() .stream()
.anyMatch(event -> event.getMessage().getFormattedMessage().contains(substring)); .anyMatch(event -> event.getMessage().getFormattedMessage().contains(substring));
} }
@@ -80,7 +92,7 @@ class TestLogCapture {
* @return list of formatted log messages * @return list of formatted log messages
*/ */
List<String> getMessages() { List<String> getMessages() {
return this.listAppender.getEvents() return listAppender.getEvents()
.stream() .stream()
.map(event -> event.getMessage().getFormattedMessage()) .map(event -> event.getMessage().getFormattedMessage())
.toList(); .toList();
@@ -90,25 +102,24 @@ class TestLogCapture {
* Clears all captured log events. * Clears all captured log events.
*/ */
void clear() { void clear() {
this.listAppender.getEvents().clear(); listAppender.clear();
} }
/** /**
* Internal ListAppender for collecting log events. * Internal appender that collects immutable copies of log events.
*/ */
private static class ListAppender extends AbstractAppender { private static class ListAppender extends AbstractAppender {
private final List<org.apache.logging.log4j.core.LogEvent> events = new ArrayList<>(); private final List<LogEvent> events = new ArrayList<>();
ListAppender() { ListAppender(String name) {
super("TestListAppender", null, null, false); super(name, null, null, false);
// Set filter and layout to ensure events are processed
} }
@Override @Override
public void append(org.apache.logging.log4j.core.LogEvent event) { public void append(LogEvent event) {
// Copy event to ensure it's captured even if the original is reused // Store an immutable copy to prevent issues with Log4j2 event object reuse
this.events.add(event); events.add(event.toImmutable());
} }
@Override @Override
@@ -116,8 +127,12 @@ class TestLogCapture {
return false; return false;
} }
List<org.apache.logging.log4j.core.LogEvent> getEvents() { List<LogEvent> getEvents() {
return new ArrayList<>(this.events); return new ArrayList<>(events);
}
void clear() {
events.clear();
} }
} }
} }