package com.nanri.aiimage.config; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** * task-190:日志脱敏契约(spec 11 §2)。 * Token 部分脱敏规则单一来源(StructuredLog.maskToken,ImageVideo 已委托);结构化字段值 * 消毒防日志注入;重试守卫的 URL(去 query)/异常消息体不入日志,保证敏感原文不落盘。 */ class LogRedactionTest { @Test void shortTokenFullyMasked() { assertEquals("***", StructuredLog.maskToken("abc123")); } @Test void longTokenPartiallyMaskedNoOriginal() { String original = "abcdefghij-ABCDEFGHIJ-secret-token-000"; String masked = StructuredLog.maskToken(original); assertEquals(original.substring(0, 6) + "***" + original.substring(original.length() - 4), masked); assertFalse(masked.contains(original), "脱敏结果不应包含原始 token"); } @Test void nullBlankTokenMaskedEmpty() { assertEquals("", StructuredLog.maskToken(null)); assertEquals("", StructuredLog.maskToken(" ")); } @Test void maskingDeterministic() { String token = "abcdefghijk-1234567890"; assertEquals(StructuredLog.maskToken(token), StructuredLog.maskToken(token)); } @Test void structuredFieldStripsNewlineNoLogInjection() { String value = StructuredLog.field("正常前缀\r\nInject: stolen; more"); assertFalse(value.contains("\r"), "字段值不应含回车"); assertFalse(value.contains("\n"), "字段值不应含换行,防止日志注入"); } @Test void guardStripsTokenFromUrlQueryInLog() throws Exception { IdempotentRetryGuard guard = new IdempotentRetryGuard(fastProps()); ListAppender captured = attach(); try { AtomicInteger n = new AtomicInteger(); guard.execute("POST", true, "brand", "http://example/x?token=SECRET-QUERY-999", () -> { if (n.incrementAndGet() < 2) { throw new IOException("连接中断"); } return "ok"; }); String text = join(captured); assertFalse(text.contains("SECRET-QUERY-999"), "URL query 中的 token 不应入日志: " + text); } finally { detach(captured); } } @Test void guardExceptionMessageNotLogged() throws Exception { IdempotentRetryGuard guard = new IdempotentRetryGuard(fastProps()); ListAppender captured = attach(); try { AtomicInteger n = new AtomicInteger(); guard.execute("POST", true, "brand", "http://example/x", () -> { if (n.incrementAndGet() < 2) { throw new IOException("响应: Bearer secret-token-abc-9999"); } return "ok"; }); String text = join(captured); assertFalse(text.contains("secret-token-abc-9999"), "异常消息体不应入日志: " + text); assertFalse(text.contains("Bearer"), "鉴权字样不应入日志"); } finally { detach(captured); } } @Test void normalFieldsKeptWhileSensitiveMasked() { // 普通字段原样保留;敏感字段走 maskToken 后不再含原文 assertTrue(StructuredLog.format(Map.of("taskId", 7L)).contains("taskId=7"), "普通字段应保留"); String token = "normal-token-value-1234567890"; assertFalse(StructuredLog.maskToken(token).equals(token), "maskToken 不应原样返回 token"); } private static HttpClientProperties fastProps() { HttpClientProperties p = new HttpClientProperties(); p.setBaseRetryDelayMillis(1); return p; } private static ListAppender attach() { Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class); ListAppender appender = new ListAppender<>(); appender.start(); guardLogger.addAppender(appender); return appender; } private static void detach(ListAppender appender) { ((Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class)).detachAppender(appender); } private static String join(ListAppender appender) { return String.join("\n", appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList()); } }