对接规范§3:外观偏好同源读端点 /api/appearance(自建同构哑管道,503铁律,回环验签链 userbase→userhome,env OPENGW_APPEARANCE_APP_KEY 点燃)
build / build-push (push) Successful in 2m11s
build / build-push (push) Successful in 2m11s
This commit is contained in:
@@ -47,6 +47,13 @@ public class RoutingFilter implements Filter {
|
||||
/** springdoc UI 传统入口;{@link #isSelfPath} 的 equals+前缀双条件均不覆盖此路径,须单独 equals 判定 */
|
||||
public static final String SWAGGER_UI_HTML = "/swagger-ui.html";
|
||||
|
||||
/**
|
||||
* 外观偏好同源读端点(对接规范 §3;{@code AppearanceProxyController}):YschTheme 通道 init
|
||||
* 固定打「serviceHost + /api/appearance」裸路径,不在 {@code /api/internal} 圈内——
|
||||
* 须与 {@link #INTERNAL_PREFIX} 同级旁路,否则首段 api 会被当 target 解析 → 裸 404。
|
||||
*/
|
||||
public static final String APPEARANCE_PATH = "/api/appearance";
|
||||
|
||||
/** 路由产物 attribute({@link TargetRouter.Routing}) */
|
||||
public static final String ATTR_ROUTING = "yh.gateway.routing";
|
||||
/** 注册表策略 attribute({@link RouteSpec}) */
|
||||
@@ -103,7 +110,8 @@ public class RoutingFilter implements Filter {
|
||||
|| isSelfPath(uri, ACTUATOR_PREFIX)
|
||||
|| isSelfPath(uri, API_DOCS_PREFIX)
|
||||
|| isSelfPath(uri, SWAGGER_UI_PREFIX)
|
||||
|| (uri != null && uri.equals(SWAGGER_UI_HTML));
|
||||
|| (uri != null && uri.equals(SWAGGER_UI_HTML))
|
||||
|| (uri != null && uri.equals(APPEARANCE_PATH));
|
||||
}
|
||||
|
||||
private static boolean isSelfPath(String uri, String prefix) {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.RequestBuilder;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.jasig.cas.client.util.AbstractCasFilter;
|
||||
import org.jasig.cas.client.validation.Assertion;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 外观偏好同源读端点 {@code GET /api/appearance}(对接规范 §3 外观契约,网关端实现)。
|
||||
* <p>
|
||||
* <b>为什么要有</b>:YschTheme 通道 init 的异步补齐段按「相对本应用 serviceHost 的
|
||||
* /api/appearance」取服务端真源(manifest.appearance),收敛 yh-theme / yh-avatar /
|
||||
* yh-logo-url 三键。Notice/svc-hall/flow-center 由各自后端的 AppearanceProxyController 提供,
|
||||
* 网关按同构形态自建(平台 starter {@code yh-appearance-spring-boot-starter} 尚在
|
||||
* feat/appearance-sdk 分支未发布,且网关无 YschOpenCall/BoaUser 六件套——规范文档 §7
|
||||
* 允许"自建同构哑管道,503 铁律不可省")。
|
||||
* <p>
|
||||
* <b>链路</b>:浏览器(通道 init,同源带 OPENGW_SESSION)→ 本端点从 CAS 会话取登录名
|
||||
* (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后
|
||||
* <b>回环打网关真实 oapi 链</b>(同 ProxyTestController 形态:Sanitize→路由→验签→转发):
|
||||
* ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance}
|
||||
* 取生效外观;②的响应体【整体透传】(裸 payload {theme, avatar, logoUrl}),
|
||||
* 字段演进只在 UserHome+通道两端,本端零改动。
|
||||
* <p>
|
||||
* <b>三态失败语义</b>:无 CAS 会话/会话断言缺失 → 401;未配
|
||||
* {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome
|
||||
* 不可达、user 解析不到、上游非 200、响应非 JSON —— 一律 sendError(503) 直写绕过
|
||||
* advice/全局异常处理器。init 对任何非 2xx【静默保键】:网关/userhome 故障不会
|
||||
* 清空浏览器本地观感(503 铁律,Notice 线上事故 0721cf3 教训)。绝不 200+全 null。
|
||||
* <p>
|
||||
* <b>信封豁免</b>:返回类型 String + produces=application/json 走 StringHttpMessageConverter,
|
||||
* {@code ApiResponseAdvice.supports()} 对该 converter 直接跳过——裸 payload 透传不被
|
||||
* {code:0} 信封二次包装(init 见 code 字段会当异常形态跳过收敛)。
|
||||
* <p>
|
||||
* 本路径不在 CAS 过滤器圈(urlPattern=/api/internal/admin/*)内,鉴权由本类自查会话断言
|
||||
* 完成——CAS 面未装配(开关关)时恒 401,行为与"未登录"一致,无需额外防线。
|
||||
*/
|
||||
@Tag(name = "GatewayAppearance", description = "网关治理面外观偏好同源读(通道 init 消费)")
|
||||
@RestController
|
||||
public class AppearanceProxyController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AppearanceProxyController.class);
|
||||
|
||||
/** open_api_target 注册表中的目标码(与路由表同源,勿写死 host——寻址归注册表热重载) */
|
||||
static final String TARGET_USERBASE = "userbase";
|
||||
static final String TARGET_USERHOME = "userhome";
|
||||
|
||||
/** 回环两段各自超时:连接 2s / 读 3s——init 总超时 3s,本端点必须更快失败 */
|
||||
private static final int CONNECT_TIMEOUT_MS = 2_000;
|
||||
private static final int READ_TIMEOUT_MS = 3_000;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private GatewayKeyService keyService;
|
||||
|
||||
@Value("${yh.gateway.appearance.app-key:}")
|
||||
private String appKey;
|
||||
|
||||
/** 回环基址覆盖(单测注入本地 HttpServer 用);null = 按请求 localAddr:localPort 推导 */
|
||||
private String loopbackBaseOverride;
|
||||
|
||||
void setLoopbackBase(String base) {
|
||||
this.loopbackBaseOverride = base;
|
||||
}
|
||||
|
||||
void setKeyService(GatewayKeyService keyService) {
|
||||
this.keyService = keyService;
|
||||
}
|
||||
|
||||
void setAppKey(String appKey) {
|
||||
this.appKey = appKey;
|
||||
}
|
||||
|
||||
@Operation(operationId = "GatewayAppearance.appearance", summary = "当前登录人生效外观(裸 payload 透传)")
|
||||
@GetMapping(value = "/api/appearance", produces = "application/json")
|
||||
public String appearance(HttpServletRequest req, HttpServletResponse res) throws IOException {
|
||||
String loginName = principalOf(req);
|
||||
if (loginName == null || loginName.isEmpty()) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "login required");
|
||||
return null;
|
||||
}
|
||||
if (appKey == null || appKey.trim().isEmpty()) {
|
||||
// 灰度关:不配 app-key 即整组静默(init 401/503 均保键),与 CAS 开关同哲学
|
||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance proxy disabled");
|
||||
return null;
|
||||
}
|
||||
String secret = keyService.getSecret(appKey.trim()); // fail-closed:未知/禁用/Redis 异常一律 null
|
||||
if (secret == null) {
|
||||
LOG.warn("[appearance] 密钥链解析不到 appKey={}(503,init 侧静默不动本地键)", appKey);
|
||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String base = loopbackBase(req);
|
||||
JsonNode user = signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName",
|
||||
"loginName=" + urlEncode(loginName), appKey.trim(), secret);
|
||||
if (user == null || user.isNull() || !user.path("id").canConvertToLong()) {
|
||||
LOG.warn("[appearance] userbase 未解析到登录人 {} 的 userId(503)", loginName);
|
||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
||||
return null;
|
||||
}
|
||||
String payload = signedGetBody(base, TARGET_USERHOME, "/oapi/appearance",
|
||||
"userId=" + user.path("id").asLong(), appKey.trim(), secret);
|
||||
if (payload == null) {
|
||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (Exception e) {
|
||||
// 降级铁律:sendError 直写 503 绕过全局异常处理器(否则被兜成 200 信封,
|
||||
// init 会把意外形态当脏值跳过——语义上浏览器必须拿到非 2xx)
|
||||
LOG.warn("[appearance] 上游外观链路不可达(503,init 侧静默不动本地键)login={}:{}",
|
||||
loginName, e.toString());
|
||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话断言里的登录名;无会话/未校验返回 null(同 SsoSessionController.principalOf) */
|
||||
private static String principalOf(HttpServletRequest req) {
|
||||
HttpSession session = req.getSession(false);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object attribute = session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
|
||||
if (!(attribute instanceof Assertion)) {
|
||||
return null;
|
||||
}
|
||||
return ((Assertion) attribute).getPrincipal() == null
|
||||
? null : ((Assertion) attribute).getPrincipal().getName();
|
||||
}
|
||||
|
||||
/** 回环 GET 并解析为 JSON(非 200/非 JSON 返回 null,fail-closed) */
|
||||
private JsonNode signedGetJson(String base, String target, String strippedPath, String query,
|
||||
String key, String secret) throws IOException {
|
||||
String body = signedGetBody(base, target, strippedPath, query, key, secret);
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return mapper.readTree(body);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名回环 GET:canonical 复用 {@link SignVerifyFilter#buildCanonicalTarget}
|
||||
* (与验签侧同一实现,杜绝双写漂移),时间戳毫秒(F1 契约)。200 返体,其余返 null。
|
||||
*/
|
||||
private String signedGetBody(String base, String target, String strippedPath, String query,
|
||||
String key, String secret) throws IOException {
|
||||
String pathAndQuery = "/" + target + strippedPath + (query == null || query.isEmpty() ? "" : "?" + query);
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
String canonical = SignVerifyFilter.buildCanonicalTarget(strippedPath,
|
||||
query == null || query.isEmpty() ? null : query);
|
||||
String signature = SignUtil.signRequest("GET", canonical, timestamp, secret);
|
||||
|
||||
try (CloseableHttpClient client = HttpClients.createDefault()) {
|
||||
RequestBuilder rb = RequestBuilder.get()
|
||||
.setUri(URI.create(base + pathAndQuery))
|
||||
.setConfig(RequestConfig.custom()
|
||||
.setConnectTimeout(CONNECT_TIMEOUT_MS)
|
||||
.setSocketTimeout(READ_TIMEOUT_MS)
|
||||
.setConnectionRequestTimeout(CONNECT_TIMEOUT_MS)
|
||||
.setRedirectsEnabled(false)
|
||||
.build())
|
||||
.addHeader("X-App-Key", key)
|
||||
.addHeader("X-Timestamp", timestamp)
|
||||
.addHeader("X-Signature", signature);
|
||||
try (CloseableHttpResponse up = client.execute(rb.build())) {
|
||||
int status = up.getStatusLine().getStatusCode();
|
||||
String body = up.getEntity() == null ? "" : EntityUtils.toString(up.getEntity(), StandardCharsets.UTF_8);
|
||||
return status == 200 ? body : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String urlEncode(String v) {
|
||||
try {
|
||||
return URLEncoder.encode(v, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同 ProxyTestController:取本机直打地址(不经 Caddy),天然内部入口口径(一律验签) */
|
||||
private String loopbackBase(HttpServletRequest req) {
|
||||
if (loopbackBaseOverride != null) {
|
||||
return loopbackBaseOverride;
|
||||
}
|
||||
String host = req.getLocalAddr();
|
||||
if (host != null && host.contains(":") && !host.startsWith("[")) {
|
||||
host = "[" + host + "]";
|
||||
}
|
||||
return "http://" + host + ":" + req.getLocalPort();
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,11 @@ yh:
|
||||
# 机器面(/api/internal/** 除浏览器面)的共享内部令牌;值只在应用 env,不进代码不进库。
|
||||
# 空 = 不校验(灰度窗口态:网关先上、env 后配,期间不打断 projman 的路由同步)。
|
||||
internal-token: ${OPENGW_INTERNAL_TOKEN:}
|
||||
appearance:
|
||||
# 外观偏好同源读端点 /api/appearance(对接规范 §3;AppearanceProxyController):
|
||||
# 用网关注册在自身密钥链里的 appKey 签名回环 oapi 链(userbase byLoginName → userhome appearance)。
|
||||
# 空 = 端点恒 503(灰度关,通道 init 静默保键);上线值由平台应用 env 注入。
|
||||
app-key: ${OPENGW_APPEARANCE_APP_KEY:}
|
||||
sso:
|
||||
# 治理面浏览器 SSO(CAS 会话)总开关。默认关——由平台应用 env OPENGW_SSO_ENABLED=true 点燃,
|
||||
# 回滚 = 清掉该 env 重新 Apply(一次重启,不回退镜像、不改 Caddy)。
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import org.jasig.cas.client.authentication.AttributePrincipal;
|
||||
import org.jasig.cas.client.util.AbstractCasFilter;
|
||||
import org.jasig.cas.client.validation.Assertion;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 外观偏好同源读端点契约(对接规范 §3,网关自建同构哑管道):
|
||||
* <ul>
|
||||
* <li>无会话断言 → 401;未配 app-key / 密钥链解析不到 → 503(绝不 200+兜底值);</li>
|
||||
* <li>happy path:byLoginName 换 userId → appearance 裸 payload 整体透传(无信封);</li>
|
||||
* <li>上游任一环节非 200 / 解析不到 userId → 503(init 侧静默保键语义的前提)。</li>
|
||||
* </ul>
|
||||
* 回环用 JDK HttpServer 按路径分桩(standalone MockMvc 不装 advice,String 返回即裸体)。
|
||||
*/
|
||||
class AppearanceProxyControllerTest {
|
||||
|
||||
private static final String SECRET = "probe-secret-0123456789";
|
||||
private static final String PAYLOAD =
|
||||
"{\"theme\":{\"theme\":\"blue\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true},"
|
||||
+ "\"avatar\":\"text\",\"logoUrl\":\"http://cdn/logo.png\"}";
|
||||
|
||||
private HttpServer server;
|
||||
private final Map<String, String> stubBodies = new HashMap<>();
|
||||
private final Map<String, Integer> stubStatus = new HashMap<>();
|
||||
|
||||
private AppearanceProxyController controller;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", new StubHandler());
|
||||
server.start();
|
||||
|
||||
GatewayKeyService keyService = Mockito.mock(GatewayKeyService.class);
|
||||
Mockito.when(keyService.getSecret("gw-appearance")).thenReturn(SECRET);
|
||||
Mockito.when(keyService.getSecret("gw-unknown")).thenReturn(null);
|
||||
|
||||
controller = new AppearanceProxyController();
|
||||
controller.setKeyService(keyService);
|
||||
controller.setLoopbackBase("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private class StubHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange ex) throws IOException {
|
||||
String path = ex.getRequestURI().getPath();
|
||||
int status = stubStatus.getOrDefault(path, 200);
|
||||
String body = stubBodies.getOrDefault(path, "{}");
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().set("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(status, status == 204 ? -1 : bytes.length);
|
||||
if (status != 204) {
|
||||
try (OutputStream os = ex.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MockHttpSession sessionWithPrincipal(String name) {
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
AttributePrincipal principal = Mockito.mock(AttributePrincipal.class);
|
||||
Mockito.when(principal.getName()).thenReturn(name);
|
||||
Assertion assertion = Mockito.mock(Assertion.class);
|
||||
Mockito.when(assertion.getPrincipal()).thenReturn(principal);
|
||||
session.setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, assertion);
|
||||
return session;
|
||||
}
|
||||
|
||||
@Test
|
||||
void noSession_is401() throws Exception {
|
||||
controller.setAppKey("gw-appearance");
|
||||
mvc.perform(get("/api/appearance")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAppKey_is503() throws Exception {
|
||||
controller.setAppKey("");
|
||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||
.andExpect(status().isServiceUnavailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyNotInChain_is503() throws Exception {
|
||||
controller.setAppKey("gw-unknown");
|
||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||
.andExpect(status().isServiceUnavailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPath_passesThroughBarePayload() throws Exception {
|
||||
controller.setAppKey("gw-appearance");
|
||||
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353,\"loginName\":\"admin\"}");
|
||||
stubBodies.put("/userhome/oapi/appearance", PAYLOAD);
|
||||
|
||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json(PAYLOAD));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userNotFound_is503() throws Exception {
|
||||
controller.setAppKey("gw-appearance");
|
||||
stubBodies.put("/userbase/oapi/user/byLoginName", "null");
|
||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost")))
|
||||
.andExpect(status().isServiceUnavailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void upstreamError_is503() throws Exception {
|
||||
controller.setAppKey("gw-appearance");
|
||||
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353}");
|
||||
stubStatus.put("/userhome/oapi/appearance", 404);
|
||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||
.andExpect(status().isServiceUnavailable());
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,14 @@ class SsoSurfaceGuardTest {
|
||||
private static final Set<String> MACHINE_PATHS =
|
||||
new TreeSet<>(Collections.singletonList("/api/internal/routes/refresh"));
|
||||
|
||||
/**
|
||||
* 自守卫浏览器端点:不在 CAS url-pattern 圈内(通道 init 固定打「serviceHost+/api/appearance」
|
||||
* 裸路径,无法挪进 /api/internal/admin/*),由控制器自查会话断言(无会话 401、
|
||||
* 上游不可达 503,见 AppearanceProxyController 三态语义)。
|
||||
*/
|
||||
private static final Set<String> SELF_GUARDED_BROWSER_PATHS =
|
||||
new TreeSet<>(Collections.singletonList("/api/appearance"));
|
||||
|
||||
/** 必须留在鉴权面之外的路径(本次线上探针实测过的形态) */
|
||||
private static final String[] MUST_STAY_OUTSIDE = {
|
||||
"/notice/oapi/x",
|
||||
@@ -137,8 +145,9 @@ class SsoSurfaceGuardTest {
|
||||
Set<String> paths = allControllerPaths();
|
||||
assertThat(paths).isNotEmpty();
|
||||
for (String path : paths) {
|
||||
assertThat(matched(properties.getUrlPattern(), path) || MACHINE_PATHS.contains(path))
|
||||
.as("映射 %s 既不在 CAS 治理面(%s)内,也未登记为机器面", path, properties.getUrlPattern())
|
||||
assertThat(matched(properties.getUrlPattern(), path) || MACHINE_PATHS.contains(path)
|
||||
|| SELF_GUARDED_BROWSER_PATHS.contains(path))
|
||||
.as("映射 %s 既不在 CAS 治理面(%s)内,也未登记为机器面/自守卫浏览器端点", path, properties.getUrlPattern())
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user