|
| 1 | +package com.yqz.openblog.gateway.filter; |
| 2 | + |
| 3 | +import ch.qos.logback.classic.Level; |
| 4 | +import ch.qos.logback.classic.Logger; |
| 5 | +import ch.qos.logback.classic.spi.ILoggingEvent; |
| 6 | +import ch.qos.logback.core.read.ListAppender; |
| 7 | +import com.yqz.openblog.gateway.config.GatewayProperties; |
| 8 | +import org.junit.jupiter.api.AfterEach; |
| 9 | +import org.junit.jupiter.api.BeforeEach; |
| 10 | +import org.junit.jupiter.api.Test; |
| 11 | +import org.slf4j.LoggerFactory; |
| 12 | +import org.springframework.http.HttpStatus; |
| 13 | +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; |
| 14 | +import org.springframework.mock.web.server.MockServerWebExchange; |
| 15 | +import org.springframework.web.server.ServerWebExchange; |
| 16 | +import reactor.core.Disposable; |
| 17 | +import reactor.core.publisher.Mono; |
| 18 | + |
| 19 | +import java.util.concurrent.atomic.AtomicBoolean; |
| 20 | + |
| 21 | +import static org.assertj.core.api.Assertions.assertThat; |
| 22 | + |
| 23 | +class AccessLogFilterTest { |
| 24 | + |
| 25 | + private final GatewayProperties props = new GatewayProperties(); |
| 26 | + private final AccessLogFilter filter = new AccessLogFilter(props); |
| 27 | + |
| 28 | + private Logger filterLogger; |
| 29 | + private ListAppender<ILoggingEvent> appender; |
| 30 | + |
| 31 | + @BeforeEach |
| 32 | + void attachAppender() { |
| 33 | + filterLogger = (Logger) LoggerFactory.getLogger(AccessLogFilter.class); |
| 34 | + appender = new ListAppender<>(); |
| 35 | + appender.start(); |
| 36 | + filterLogger.addAppender(appender); |
| 37 | + } |
| 38 | + |
| 39 | + @AfterEach |
| 40 | + void detachAppender() { |
| 41 | + filterLogger.detachAppender(appender); |
| 42 | + } |
| 43 | + |
| 44 | + @Test |
| 45 | + void logsIpMethodPathStatusAndTraceId() { |
| 46 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 47 | + MockServerHttpRequest.get("/api/v1/articles?keyword=spring") |
| 48 | + .header("X-Real-IP", "203.0.113.7") |
| 49 | + .build()); |
| 50 | + // 单测直接调本过滤器,链上没有 TraceIdFilter:手动写入它本该留下的属性。 |
| 51 | + // 生产环境两者都是 GlobalFilter,TraceIdFilter(-3) 先于本过滤器(-4) 收尾,属性必然已就位。 |
| 52 | + exchange.getAttributes().put(TraceIdFilter.TRACE_ID_ATTR, "t-abc"); |
| 53 | + exchange.getResponse().setStatusCode(HttpStatus.OK); |
| 54 | + |
| 55 | + filter.filter(exchange, ex -> Mono.empty()).block(); |
| 56 | + |
| 57 | + ILoggingEvent event = onlyEvent(); |
| 58 | + assertThat(event.getLevel()).isEqualTo(Level.INFO); |
| 59 | + assertThat(event.getFormattedMessage()) |
| 60 | + .contains("ip=203.0.113.7") |
| 61 | + .contains("method=GET") |
| 62 | + .contains("path=/api/v1/articles") |
| 63 | + .contains("status=200") |
| 64 | + .contains("traceId=t-abc") |
| 65 | + .contains("cost="); |
| 66 | + // query string 刻意不入日志(防一次性令牌/邮箱等敏感参数落盘) |
| 67 | + assertThat(event.getFormattedMessage()).doesNotContain("keyword=spring"); |
| 68 | + } |
| 69 | + |
| 70 | + @Test |
| 71 | + void prefersRealIpOverForwardedFor() { |
| 72 | + // 与限流共用 ClientIpResolver:nginx 覆写的 X-Real-IP 优先于客户端可伪造的 XFF |
| 73 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 74 | + MockServerHttpRequest.get("/api/v1/x") |
| 75 | + .header("X-Forwarded-For", "1.2.3.4") |
| 76 | + .header("X-Real-IP", "9.9.9.9") |
| 77 | + .build()); |
| 78 | + |
| 79 | + filter.filter(exchange, ex -> Mono.empty()).block(); |
| 80 | + |
| 81 | + assertThat(onlyEvent().getFormattedMessage()).contains("ip=9.9.9.9"); |
| 82 | + } |
| 83 | + |
| 84 | + @Test |
| 85 | + void logsFailureAsWarningWhenChainErrors() { |
| 86 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 87 | + MockServerHttpRequest.get("/api/v1/x").header("X-Real-IP", "1.2.3.4").build()); |
| 88 | + |
| 89 | + filter.filter(exchange, ex -> Mono.error(new IllegalStateException("boom"))) |
| 90 | + .onErrorResume(e -> Mono.empty()) |
| 91 | + .block(); |
| 92 | + |
| 93 | + ILoggingEvent event = onlyEvent(); |
| 94 | + assertThat(event.getLevel()).isEqualTo(Level.WARN); |
| 95 | + assertThat(event.getFormattedMessage()) |
| 96 | + .contains("gateway access failed") |
| 97 | + .contains("ip=1.2.3.4") |
| 98 | + .contains("boom"); |
| 99 | + } |
| 100 | + |
| 101 | + @Test |
| 102 | + void logsCancelledRequestAtInfo() { |
| 103 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 104 | + MockServerHttpRequest.get("/api/v1/x").header("X-Real-IP", "1.2.3.4").build()); |
| 105 | + |
| 106 | + // 模拟客户端提前断开:订阅后立即 dispose,链路以 ON_CANCEL 终止 |
| 107 | + Disposable subscription = filter.filter(exchange, ex -> Mono.never()).subscribe(); |
| 108 | + subscription.dispose(); |
| 109 | + |
| 110 | + ILoggingEvent event = onlyEvent(); |
| 111 | + // 取消属日常流量(前端切路由/关页面),记 INFO 以免刷屏淹没真正的失败 |
| 112 | + assertThat(event.getLevel()).isEqualTo(Level.INFO); |
| 113 | + assertThat(event.getFormattedMessage()) |
| 114 | + .contains("gateway access cancelled") |
| 115 | + .contains("ip=1.2.3.4"); |
| 116 | + } |
| 117 | + |
| 118 | + @Test |
| 119 | + void stillLogsWhenForwardedForIsCommaOnly() { |
| 120 | + // 回归防线:",".split(",") 得到长度 0 的数组,老写法取 [0] 会抛 AIOOBE; |
| 121 | + // 该异常在 doFinally 里被 Reactor 吞掉,请求方用畸形头就让自己的访问日志整条消失。 |
| 122 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 123 | + MockServerHttpRequest.get("/api/v1/x").header("X-Forwarded-For", ",").build()); |
| 124 | + |
| 125 | + filter.filter(exchange, ex -> Mono.empty()).block(); |
| 126 | + |
| 127 | + assertThat(onlyEvent().getFormattedMessage()).contains("ip="); |
| 128 | + } |
| 129 | + |
| 130 | + @Test |
| 131 | + void trimsWhitespaceSoRateLimitBucketsCannotBeRotated() { |
| 132 | + // 带空格的头若原样入 key,"1.2.3.4 " 与 "1.2.3.4" 会落进不同限流桶 |
| 133 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 134 | + MockServerHttpRequest.get("/api/v1/x").header("X-Real-IP", " 1.2.3.4 ").build()); |
| 135 | + |
| 136 | + filter.filter(exchange, ex -> Mono.empty()).block(); |
| 137 | + |
| 138 | + // 断言到下一个字段的边界,才能证明首尾空格真的被去掉了 |
| 139 | + assertThat(onlyEvent().getFormattedMessage()).contains("ip=1.2.3.4 method="); |
| 140 | + } |
| 141 | + |
| 142 | + @Test |
| 143 | + void forwardsWithoutLoggingWhenDisabled() { |
| 144 | + props.getAccessLog().setEnabled(false); |
| 145 | + ServerWebExchange exchange = MockServerWebExchange.from( |
| 146 | + MockServerHttpRequest.get("/api/v1/x").build()); |
| 147 | + AtomicBoolean forwarded = new AtomicBoolean(false); |
| 148 | + |
| 149 | + filter.filter(exchange, ex -> { |
| 150 | + forwarded.set(true); |
| 151 | + return Mono.empty(); |
| 152 | + }).block(); |
| 153 | + |
| 154 | + assertThat(forwarded).isTrue(); |
| 155 | + assertThat(appender.list).isEmpty(); |
| 156 | + } |
| 157 | + |
| 158 | + @Test |
| 159 | + void runsOutsideTraceIdFilterSoTraceIdIsAvailable() { |
| 160 | + // 顺序是契约的一部分:必须在 TraceIdFilter 外层,否则记录时 traceId 还没写入 exchange |
| 161 | + assertThat(filter.getOrder()).isLessThan(new TraceIdFilter().getOrder()); |
| 162 | + } |
| 163 | + |
| 164 | + private ILoggingEvent onlyEvent() { |
| 165 | + assertThat(appender.list).hasSize(1); |
| 166 | + return appender.list.get(0); |
| 167 | + } |
| 168 | +} |
0 commit comments