- 治理面浏览器 SSO(/api/internal/admin/*):cas-client-core 3.5.1 固定 service 票据校验
(ctx 为空、外链前缀由 Caddy 剥离,按请求路径拼 service 必 404,故用固定回跳靶点);
未登录双形态应答——带 yschapirequest:1 出 200+{code:302,urlToRedirectTo}(平台同构,
alova 直接整页跳),裸调用出 401 治理面信封;OPENGW_SSO_ENABLED 默认关,
回滚=清 env 重新 Apply,不回退镜像
- 机器面(/api/internal/* 其余)收口 InternalTokenFilter:只认 X-Internal-Token,
值走 OPENGW_INTERNAL_TOKEN env,空=灰度放行(projman 路由同步不打断);
比较用 XOR 折叠常量时间(String.equals 提前短路有时序侧信道;MessageDigest.isEqual
在 JDK8 非 const-time,JDK9 才修)
- 注册表重载抽 RegistryReloader:机器面 /routes/refresh 与控制台按钮
/admin/routes/reload 共用同一实现(一接口面一种认证,不做令牌 OR 会话混判)
- 治理会话 cookie 改名 OPENGW_SESSION:ctx 为空时默认 JSESSIONID(Path=/) 会与
base 域各应用同名叠放进同一个 Cookie 头
- session 端点在 CAS 面未装配时回空 ssoHost:前端据此隐藏「退出」,不在匿名端点
泄漏 CAS 基址、灰度期不出现平台级注销入口
- 新增 44 用例:面守卫机械钉桩(全仓控制器映射必须声明鉴权归属)+ 装配红线
(默认关/序位/固定 service 原样采用)+ 双形态应答 + 令牌三态/灰度窗 + 重载器
This commit is contained in:
@@ -9,6 +9,7 @@ import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository;
|
||||
import com.yunhai.platform.gateway.route.RegistryReloader;
|
||||
import com.yunhai.platform.gateway.route.RouteRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -67,6 +68,8 @@ class InternalAdminControllerTest {
|
||||
|
||||
InternalAdminController controller = new InternalAdminController();
|
||||
controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, appRegistry);
|
||||
// 浏览器面的「重载注册表」入口与机器面共用 RegistryReloader(同一实现的两个口)
|
||||
controller.setReloader(new RegistryReloader(registry, appRegistry));
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
|
||||
when(routeRepo.findAll(any(Pageable.class)))
|
||||
@@ -192,6 +195,26 @@ class InternalAdminControllerTest {
|
||||
verify(registry).reloadTargets();
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制台「重载注册表」按钮走浏览器面入口,且与机器面 {@code /api/internal/routes/refresh}
|
||||
* 共用同一实现(返回体三个键形态一致)。此前该按钮直接打机器面路径,令牌收口后会被 401 拦死。
|
||||
*/
|
||||
@Test
|
||||
void consoleReload_onBrowserSurface_usesSharedReloader() throws Exception {
|
||||
when(registry.reloadTargets()).thenReturn(4);
|
||||
when(appRegistry.reload()).thenReturn(1);
|
||||
|
||||
mvc.perform(post("/api/internal/admin/routes/reload"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.loaded").value(22))
|
||||
.andExpect(jsonPath("$.targets").value(4))
|
||||
.andExpect(jsonPath("$.apps").value(1));
|
||||
|
||||
verify(registry).reload();
|
||||
verify(registry).reloadTargets();
|
||||
verify(appRegistry).reload();
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetUpdate_blankBaseUrl_bizErrorNoSave() throws Exception {
|
||||
when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L)));
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.yunhai.platform.gateway.auth;
|
||||
|
||||
import com.yunhai.platform.gateway.route.GatewayProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 机器面闸门三态(有效 / 无效 / 缺失)+ 灰度窗口态 + 默认拒绝的覆盖面。
|
||||
* <p>
|
||||
* 唯一实测调用方是 projman 的 {@code OpenApiTargetSyncService}(容器网内直连
|
||||
* {@code POST /api/internal/routes/refresh}),所以「有效令牌必须放行」这条不是装饰性用例,
|
||||
* 而是本次上线不能打断的功能;「浏览器面放过」同理——否则控制台会被要求带内部令牌。
|
||||
*/
|
||||
class InternalTokenFilterTest {
|
||||
|
||||
private static final String TOKEN = "0f1e2d3c4b5a6978";
|
||||
private static final String REFRESH = "/api/internal/routes/refresh";
|
||||
|
||||
private final List<String> passed = new ArrayList<>();
|
||||
private final FilterChain chain = (req, res) -> passed.add(((MockHttpServletRequest) req).getServletPath());
|
||||
|
||||
private static GatewayProperties properties(String token) {
|
||||
GatewayProperties properties = new GatewayProperties();
|
||||
properties.setInternalToken(token);
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest post(String path, String token) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
|
||||
req.setServletPath(path);
|
||||
if (token != null) {
|
||||
req.addHeader(InternalTokenFilter.HEADER, token);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 有效令牌放行() throws Exception {
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new InternalTokenFilter(properties(TOKEN)).doFilter(post(REFRESH, TOKEN), res, chain);
|
||||
|
||||
assertThat(passed).containsExactly(REFRESH);
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
assertThat(res.getContentAsString()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 无效令牌_401json() throws Exception {
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new InternalTokenFilter(properties(TOKEN)).doFilter(post(REFRESH, "wrong"), res, chain);
|
||||
|
||||
assertThat(passed).isEmpty();
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(res.getContentAsString()).contains("\"code\":401").doesNotContain("<html");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 缺失令牌_401json() throws Exception {
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new InternalTokenFilter(properties(TOKEN)).doFilter(post(REFRESH, null), res, chain);
|
||||
|
||||
assertThat(passed).isEmpty();
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 浏览器面放过_控制台归CAS门管() throws Exception {
|
||||
// 不带任何令牌也必须放行到下一环(CAS 门),否则登录好的控制台会被本门拦死
|
||||
MockHttpServletRequest req = post("/api/internal/admin/routes", null);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new InternalTokenFilter(properties(TOKEN)).doFilter(req, res, chain);
|
||||
|
||||
assertThat(passed).containsExactly("/api/internal/admin/routes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 浏览器面前缀本身也放过() throws Exception {
|
||||
MockHttpServletRequest req = post(InternalTokenFilter.BROWSER_SURFACE_PREFIX, null);
|
||||
|
||||
new InternalTokenFilter(properties(TOKEN)).doFilter(req, new MockHttpServletResponse(), chain);
|
||||
|
||||
assertThat(passed).containsExactly(InternalTokenFilter.BROWSER_SURFACE_PREFIX);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新加的内部端点默认要令牌_不靠人想起来保护() throws Exception {
|
||||
// 默认拒绝:落在 /api/internal/* 又不在浏览器面前缀下的任何路径都要令牌
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new InternalTokenFilter(properties(TOKEN)).doFilter(post("/api/internal/some-future-hook", null), res, chain);
|
||||
|
||||
assertThat(passed).isEmpty();
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未配令牌是灰度窗口态_放行且只出声一次() throws Exception {
|
||||
InternalTokenFilter filter = new InternalTokenFilter(properties(""));
|
||||
MockHttpServletResponse first = new MockHttpServletResponse();
|
||||
MockHttpServletResponse second = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(post(REFRESH, null), first, chain);
|
||||
filter.doFilter(post(REFRESH, "任何值"), second, chain);
|
||||
|
||||
// 配好 env 即自动收口(下一个用例的 properties(TOKEN) 就是收口后的形态)
|
||||
assertThat(passed).containsExactly(REFRESH, REFRESH);
|
||||
assertThat(first.getStatus()).isEqualTo(200);
|
||||
assertThat(second.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 令牌值空白等同未配置() throws Exception {
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new InternalTokenFilter(properties(" ")).doFilter(post(REFRESH, null), res, chain);
|
||||
|
||||
assertThat(passed).containsExactly(REFRESH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 圈定前缀是api的子集() {
|
||||
// 红线 1:闸门只准圈 /api/*;改成 /** 会拦到 /oapi 转发链
|
||||
assertThat(InternalTokenFilter.URL_PATTERN).startsWith("/api/");
|
||||
assertThat(InternalTokenFilter.URL_PATTERN).isEqualTo("/api/internal/*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.yunhai.platform.gateway.route;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 注册表重载的唯一实现(机器面 refresh 与浏览器面 reload 共用):三张快照各自重 load、
|
||||
* 返回体三个键、并按需广播。
|
||||
* <p>
|
||||
* 两个触发口共用本组件是<b>刻意的</b>:过去浏览器按钮直接打机器面路径,令牌收口后会被 401
|
||||
* 拦死(见 InternalAdminController#reload 的说明)。逻辑长在一处,两个口就不会各自漂移。
|
||||
*/
|
||||
class RegistryReloaderTest {
|
||||
|
||||
@Test
|
||||
void 三张快照各自重load且返回体三个键() {
|
||||
RouteRegistry routeRegistry = Mockito.mock(RouteRegistry.class);
|
||||
AppAuthRegistry appRegistry = Mockito.mock(AppAuthRegistry.class);
|
||||
when(routeRegistry.reload()).thenReturn(7);
|
||||
when(routeRegistry.reloadTargets()).thenReturn(3);
|
||||
when(appRegistry.reload()).thenReturn(2);
|
||||
RegistryReloader reloader = new RegistryReloader(routeRegistry, appRegistry);
|
||||
|
||||
Map<String, Object> body = reloader.reloadAll();
|
||||
|
||||
assertThat(body).containsEntry("loaded", 7).containsEntry("targets", 3).containsEntry("apps", 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 广播存在时重载后发一条变更() {
|
||||
RouteRegistry routeRegistry = Mockito.mock(RouteRegistry.class);
|
||||
AppAuthRegistry appRegistry = Mockito.mock(AppAuthRegistry.class);
|
||||
RouteChangeBroadcaster broadcaster = Mockito.mock(RouteChangeBroadcaster.class);
|
||||
RegistryReloader reloader = new RegistryReloader(routeRegistry, appRegistry);
|
||||
reloader.setBroadcaster(broadcaster);
|
||||
|
||||
reloader.reloadAll();
|
||||
|
||||
verify(broadcaster).publish();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 广播开关关掉时退化为单实例语义不炸() {
|
||||
RouteRegistry routeRegistry = Mockito.mock(RouteRegistry.class);
|
||||
AppAuthRegistry appRegistry = Mockito.mock(AppAuthRegistry.class);
|
||||
when(routeRegistry.reload()).thenReturn(1);
|
||||
// broadcaster 不注入(= redis-broadcast-enabled:false 时该 bean 不存在)
|
||||
RegistryReloader reloader = new RegistryReloader(routeRegistry, appRegistry);
|
||||
|
||||
assertThat(reloader.reloadAll()).containsEntry("loaded", 1);
|
||||
verify(routeRegistry).reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.jasig.cas.client.util.AbstractCasFilter;
|
||||
import org.jasig.cas.client.validation.Assertion;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 治理面未登录双形态(D1 决议):SPA 标记头 → HTTP 200 + {code:302,urlToRedirectTo}(Notice 同构),
|
||||
* 裸 curl / 脚本 → HTTP 401 + 治理面信封 {code:401,msg,data:null}。
|
||||
* <p>
|
||||
* 「401 必须是 json、不许是 302 HTML」是本次验收的核心判据:裸 curl 从 200 读得到路由表,
|
||||
* 变成 401 才算堵上;而 302 HTML 会让调用方以为拿到了页面。
|
||||
*/
|
||||
class BrowserAuthGateFilterTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final List<String> passed = new ArrayList<>();
|
||||
private final FilterChain chain = (req, res) -> passed.add(((MockHttpServletRequest) req).getServletPath());
|
||||
|
||||
private static SsoAddressing liveAddressing() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, "http://sso.yunhaixingtong.com/");
|
||||
env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, "http://base.yunhaixingtong.com/open-gateway/");
|
||||
SsoProperties properties = new SsoProperties();
|
||||
properties.setEnabled(true);
|
||||
return new SsoAddressing(env, properties);
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(String path, boolean ajax) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", path);
|
||||
req.setServletPath(path);
|
||||
if (ajax) {
|
||||
req.addHeader("yschapirequest", "1");
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未登录带标头_200加code302() throws Exception {
|
||||
MockHttpServletRequest req = request("/api/internal/admin/routes", true);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
assertThat(res.getContentType()).isEqualTo("application/json;charset=UTF-8");
|
||||
assertThat(passed).isEmpty();
|
||||
JsonNode node = MAPPER.readTree(res.getContentAsString());
|
||||
assertThat(node.get("code").asInt()).isEqualTo(302);
|
||||
assertThat(node.get("urlToRedirectTo").asText())
|
||||
.isEqualTo("http://sso.yunhaixingtong.com/login?service="
|
||||
+ "http%3A%2F%2Fbase.yunhaixingtong.com%2Fopen-gateway%2Fapi%2Finternal%2Fadmin%2Fsso-entry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未登录裸调用_401加json信封() throws Exception {
|
||||
MockHttpServletRequest req = request("/api/internal/admin/routes", false);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(res.getContentAsString()).doesNotContain("<html");
|
||||
JsonNode node = MAPPER.readTree(res.getContentAsString());
|
||||
assertThat(node.get("code").asInt()).isEqualTo(401);
|
||||
assertThat(node.get("data").isNull()).isTrue();
|
||||
assertThat(passed).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 标头值不对也算裸调用() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/routes");
|
||||
req.addHeader("yschapirequest", "0");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 会话里有断言_放行到业务() throws Exception {
|
||||
MockHttpServletRequest req = request("/api/internal/admin/routes", true);
|
||||
// 真实链路里断言由 cas-client 校验成功后写入 session(useSession 默认 true)
|
||||
Assertion assertion = Mockito.mock(Assertion.class);
|
||||
req.getSession(true).setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, assertion);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
|
||||
|
||||
assertThat(passed).containsExactly("/api/internal/admin/routes");
|
||||
assertThat(res.getContentAsString()).isEmpty();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 同请求attribute断言也认_校验过滤器成功路径双写() throws Exception {
|
||||
MockHttpServletRequest req = request("/api/internal/admin/routes", true);
|
||||
req.setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, Mockito.mock(Assertion.class));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
|
||||
|
||||
assertThat(passed).containsExactly("/api/internal/admin/routes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 无会话时不去新建会话() throws Exception {
|
||||
// 门本身不建会话:否则每个裸 curl 都会在网关堆一个空会话(DoS 面)
|
||||
MockHttpServletRequest req = request("/api/internal/admin/routes", false);
|
||||
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, new MockHttpServletResponse(), chain);
|
||||
|
||||
assertThat(req.getSession(false)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 票据校验失败出口(stale/重放 ticket):必须仍是双形态未登录应答,而不是 cas-client 默认的
|
||||
* 空 200({@code onFailedValidation} 空实现 + 链断)或 500 HTML({@code exceptionOnValidationFailure}
|
||||
* 默认 true 抛 ServletException)。
|
||||
* <p>
|
||||
* 只测本类拥有的那一个覆写点——cas-client 自身的 init-param 装配由生产 Notice 的同一套写法背书,
|
||||
* 不在这里重造断言。
|
||||
*/
|
||||
class GatewayCasValidationFilterTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private static SsoAddressing liveAddressing() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, "http://sso.yunhaixingtong.com/");
|
||||
env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, "http://base.yunhaixingtong.com/open-gateway/");
|
||||
SsoProperties properties = new SsoProperties();
|
||||
properties.setEnabled(true);
|
||||
return new SsoAddressing(env, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void SPA带废票_code302让它重新起跳() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/sso-entry");
|
||||
req.addHeader("yschapirequest", "1");
|
||||
req.setParameter("ticket", "ST-already-used");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new GatewayCasValidationFilter(liveAddressing()).onFailedValidation(req, res);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
JsonNode node = MAPPER.readTree(res.getContentAsString());
|
||||
assertThat(node.get("code").asInt()).isEqualTo(302);
|
||||
assertThat(node.get("urlToRedirectTo").asText()).startsWith("http://sso.yunhaixingtong.com/login?service=");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 裸调用带废票_401json不是500HTML() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/sso-entry");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
new GatewayCasValidationFilter(liveAddressing()).onFailedValidation(req, res);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(res.getContentAsString()).doesNotContain("<html");
|
||||
assertThat(res.getContentAsString()).contains("\"code\":401");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* CAS 地址推导(本次接入的命门):service / 登录地址必须带上 Caddy 剥掉、容器侧不存在的外链前缀。
|
||||
* <p>
|
||||
* 取值用 <b>160 容器里的实测值</b>({@code docker exec open-gateway-service env} 读到的
|
||||
* {@code YH_SSO_SERVER_URL_PREFIX} / {@code YH_SSO_CLIENT_HOST_URL}),不是随手写的样例——
|
||||
* 平台注入形态(带不带尾斜杠、带不带前缀)本身就是被测对象。
|
||||
* <p>
|
||||
* 反例锚点:基础平台 starter 会推导出 {@code http://base.yunhaixingtong.com/api/...}(丢前缀),
|
||||
* 该地址在 base 域实测 404;Notice 之所以没事,是它 ctx=/notice、容器 URI 自带前缀。
|
||||
*/
|
||||
class SsoAddressingTest {
|
||||
|
||||
/** 160 实测注入值(注意都带尾斜杠) */
|
||||
private static final String SSO_PREFIX = "http://sso.yunhaixingtong.com/";
|
||||
private static final String CLIENT_HOST = "http://base.yunhaixingtong.com/open-gateway/";
|
||||
|
||||
private static SsoAddressing addressing(String serverUrlPrefix, String clientHostUrl, boolean enabled) {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
if (serverUrlPrefix != null) {
|
||||
env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, serverUrlPrefix);
|
||||
}
|
||||
if (clientHostUrl != null) {
|
||||
env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, clientHostUrl);
|
||||
}
|
||||
SsoProperties properties = new SsoProperties();
|
||||
properties.setEnabled(enabled);
|
||||
return new SsoAddressing(env, properties);
|
||||
}
|
||||
|
||||
private static SsoAddressing live() {
|
||||
return addressing(SSO_PREFIX, CLIENT_HOST, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 尾斜杠归一与外链前缀保留() {
|
||||
SsoAddressing a = live();
|
||||
assertThat(a.casServerUrlPrefix()).isEqualTo("http://sso.yunhaixingtong.com");
|
||||
assertThat(a.externalBaseUrl()).isEqualTo("http://base.yunhaixingtong.com/open-gateway");
|
||||
// 不给 cas-client 的 serverName(与 service 互斥,AbstractCasFilter.init 直接 assert):
|
||||
// 治理面的 service 全靠 externalBaseUrl 拼,路径段必须原样保住
|
||||
assertThat(a.externalBaseUrl()).endsWith("/open-gateway");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 固定service是外部可达且带前缀的会话建立端点() {
|
||||
assertThat(live().serviceUrl())
|
||||
.isEqualTo("http://base.yunhaixingtong.com/open-gateway/api/internal/admin/sso-entry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 登录地址与Notice同式() {
|
||||
// 实测 Notice 的返回形态:http://sso.yunhaixingtong.com/login?service=http%3A%2F%2Fbase...%2Fnotice%2Fapi%2F__probe
|
||||
assertThat(live().loginUrl()).isEqualTo(
|
||||
"http://sso.yunhaixingtong.com/login?service="
|
||||
+ "http%3A%2F%2Fbase.yunhaixingtong.com%2Fopen-gateway%2Fapi%2Finternal%2Fadmin%2Fsso-entry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 登录后落控制台静态面() {
|
||||
assertThat(live().consoleUrl()).isEqualTo("http://base.yunhaixingtong.com/open-gateway/front/index.html");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 开关与平台键齐了才生效() {
|
||||
assertThat(live().active()).isTrue();
|
||||
assertThat(addressing(SSO_PREFIX, CLIENT_HOST, false).active()).isFalse();
|
||||
assertThat(addressing(null, CLIENT_HOST, true).active()).isFalse();
|
||||
assertThat(addressing(SSO_PREFIX, null, true).active()).isFalse();
|
||||
assertThat(addressing(" ", CLIENT_HOST, true).active()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 点了开关却缺平台键是可被告警的反常组合() {
|
||||
assertThat(addressing(SSO_PREFIX, null, true).enabledButUnconfigured()).isTrue();
|
||||
assertThat(live().enabledButUnconfigured()).isFalse();
|
||||
// 没点开关的沉默是正常态(本地/未上线),不报警
|
||||
assertThat(addressing(null, null, false).enabledButUnconfigured()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* jar 内配置的红线快照(本次接入只动 jar 自身,不碰 Caddy、不碰 proj39、不给网关注入 context-path)。
|
||||
* <p>
|
||||
* 直接读 {@code application.yml} 而不是断言运行期行为:这几条的失效方式正是「配错了自己不知道」——
|
||||
* 加了 ctx 会让对外路径整体错位,cookie 不改名会让治理面会话与 base 域其他应用同名叠放,
|
||||
* 开关被烧成 {@code true} 会让本地跑测与未上线环境直接点上鉴权。
|
||||
*/
|
||||
class SsoConfigBaselineTest {
|
||||
|
||||
private static PropertySource<?> jarYml() throws Exception {
|
||||
List<PropertySource<?>> loaded =
|
||||
new YamlPropertySourceLoader().load("app", new ClassPathResource("application.yml"));
|
||||
assertThat(loaded).hasSize(1);
|
||||
return loaded.get(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 治理面会话cookie改名且无contextPath() throws Exception {
|
||||
PropertySource<?> yml = jarYml();
|
||||
assertThat(yml.getProperty("server.servlet.session.cookie.name")).isEqualTo("OPENGW_SESSION");
|
||||
// ctx 必须保持空:对外前缀 /open-gateway 由 Caddy 剥离,一旦给 ctx 就双段错位
|
||||
assertThat(yml.containsProperty("server.servlet.context-path")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void SSO开关走env且默认关环境值不进jar() throws Exception {
|
||||
assertThat(jarYml().getProperty("yh.gateway.sso.enabled")).isEqualTo("${OPENGW_SSO_ENABLED:false}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 配置里的面路径与代码默认值逐字一致() throws Exception {
|
||||
// 两处不一致时,配置层改动会静默覆盖代码里的面定义
|
||||
PropertySource<?> yml = jarYml();
|
||||
SsoProperties properties = new SsoProperties();
|
||||
assertThat(yml.getProperty("yh.gateway.sso.url-pattern")).isEqualTo(properties.getUrlPattern());
|
||||
assertThat(yml.getProperty("yh.gateway.sso.entry-path")).isEqualTo(properties.getEntryPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 开放面验签开关仍是关闭态() throws Exception {
|
||||
// 网关自有 SignVerifyFilter 是唯一验签面;starter 的 AppAuthFilter 一旦打开会抢在链首
|
||||
// 按「含 target 段的完整 URI」验签 → /oapi 全量 401(红线 1)
|
||||
assertThat(jarYml().getProperty("yh.app-auth.enabled")).isEqualTo(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.mock.web.MockFilterConfig;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* CAS 两件套的装配面(红线 1 的结构化钉桩):序位、圈定路径、开关态,以及用真实 init-param
|
||||
* 跑一遍 cas-client 的装配。
|
||||
* <p>
|
||||
* 关键一条:<b>默认(不带 OPENGW_SSO_ENABLED)时两个注册都是 disabled</b>——这保证
|
||||
* 「代码已合入但没点开关」的网关与今天逐字节相同,也让回滚只需清 env、不需回退镜像。
|
||||
*/
|
||||
class SsoFilterWiringTest {
|
||||
|
||||
/** 160 容器实测的两个平台键 */
|
||||
private static final String SSO_PREFIX = "http://sso.yunhaixingtong.com/";
|
||||
private static final String CLIENT_HOST = "http://base.yunhaixingtong.com/open-gateway/";
|
||||
|
||||
private static SsoAddressing addressing(boolean enabled, String serverPrefix, String clientHost) {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
if (serverPrefix != null) {
|
||||
env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, serverPrefix);
|
||||
}
|
||||
if (clientHost != null) {
|
||||
env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, clientHost);
|
||||
}
|
||||
SsoProperties properties = new SsoProperties();
|
||||
properties.setEnabled(enabled);
|
||||
return new SsoAddressing(env, properties);
|
||||
}
|
||||
|
||||
private static SsoProperties properties(boolean enabled) {
|
||||
SsoProperties properties = new SsoProperties();
|
||||
properties.setEnabled(enabled);
|
||||
return properties;
|
||||
}
|
||||
|
||||
/** 用装配代码写下的同一份 init-param 构造 FilterConfig(不在此重复抄参数名) */
|
||||
private static MockFilterConfig filterConfig(SsoAddressing addressing) {
|
||||
MockFilterConfig config = new MockFilterConfig();
|
||||
for (Map.Entry<String, String> param
|
||||
: SsoFilterConfiguration.validationInitParams(addressing).entrySet()) {
|
||||
config.addInitParameter(param.getKey(), param.getValue());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 两个过滤器都只挂治理面且排在网关链首之前() {
|
||||
SsoProperties properties = properties(true);
|
||||
SsoAddressing addressing = addressing(true, SSO_PREFIX, CLIENT_HOST);
|
||||
SsoFilterConfiguration configuration = new SsoFilterConfiguration();
|
||||
|
||||
FilterRegistrationBean<GatewayCasValidationFilter> validation =
|
||||
configuration.casTicketValidationFilter(addressing, properties);
|
||||
FilterRegistrationBean<BrowserAuthGateFilter> gate =
|
||||
configuration.browserAuthGateFilter(addressing, properties);
|
||||
|
||||
assertThat(validation.getUrlPatterns()).containsExactly("/api/internal/admin/*");
|
||||
assertThat(gate.getUrlPatterns()).containsExactly("/api/internal/admin/*");
|
||||
// 与基础平台 CasSsoAutoConfiguration 同序位段,确保先于 @Order(0..5) 的网关自有链
|
||||
assertThat(validation.getOrder()).isEqualTo(Ordered.HIGHEST_PRECEDENCE + 4);
|
||||
assertThat(gate.getOrder()).isEqualTo(Ordered.HIGHEST_PRECEDENCE + 5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 圈定路径必须是api的子集() {
|
||||
// 红线 1:CAS 的 url-pattern 只准圈 /api/*。有人把它放宽成 /** 或 /oapi/* 时立刻断在这里
|
||||
String pattern = new SsoProperties().getUrlPattern();
|
||||
assertThat(pattern).startsWith("/api/");
|
||||
assertThat(pattern).isNotEqualTo("/api/*");
|
||||
assertThat(pattern).isEqualTo("/api/internal/admin/*");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 开关没点_两个注册都是disabled等于零行为变化() {
|
||||
SsoProperties properties = properties(false);
|
||||
SsoAddressing addressing = addressing(false, SSO_PREFIX, CLIENT_HOST);
|
||||
SsoFilterConfiguration configuration = new SsoFilterConfiguration();
|
||||
|
||||
assertThat(configuration.casTicketValidationFilter(addressing, properties).isEnabled()).isFalse();
|
||||
assertThat(configuration.browserAuthGateFilter(addressing, properties).isEnabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台键缺失时即使点了开关也不装配并出声告警() {
|
||||
SsoProperties properties = properties(true);
|
||||
SsoAddressing addressing = addressing(true, null, CLIENT_HOST);
|
||||
SsoFilterConfiguration configuration = new SsoFilterConfiguration();
|
||||
|
||||
assertThat(addressing.active()).isFalse();
|
||||
assertThat(addressing.enabledButUnconfigured()).isTrue();
|
||||
assertThat(configuration.casTicketValidationFilter(addressing, properties).isEnabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 照真实init参数跑通cas客户端的装配() throws Exception {
|
||||
// 参数名写错在 cas-client 里要么静默要么启动即抛;这里用同一份参数真跑一次 init()
|
||||
SsoAddressing addressing = addressing(true, SSO_PREFIX, CLIENT_HOST);
|
||||
GatewayCasValidationFilter filter = new GatewayCasValidationFilter(addressing);
|
||||
|
||||
assertThatCode(() -> filter.init(filterConfig(addressing))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 固定service被cas客户端原样采用_不掺容器路径() throws Exception {
|
||||
SsoAddressing addressing = addressing(true, SSO_PREFIX, CLIENT_HOST);
|
||||
GatewayCasValidationFilter filter = new GatewayCasValidationFilter(addressing);
|
||||
filter.init(filterConfig(addressing));
|
||||
|
||||
// 容器侧看到的是被 Caddy 剥掉前缀的路径(ctx 为空),解析结果必须仍是外部带前缀的固定 service
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/sso-entry");
|
||||
req.setContextPath("");
|
||||
req.setParameter("ticket", "ST-test");
|
||||
|
||||
assertThat(filter.resolvedServiceUrl(req, new MockHttpServletResponse()))
|
||||
.isEqualTo("http://base.yunhaixingtong.com/open-gateway/api/internal/admin/sso-entry");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.yunhai.platform.gateway.sso;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.InternalTokenFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
|
||||
import org.springframework.core.type.filter.AnnotationTypeFilter;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 面守卫(红线 1 的机械钉桩):网关全部 MVC 映射必须落进<b>已声明鉴权归属</b>的面之一,
|
||||
* 且 CAS 面圈定路径必须是 {@code /api/*} 的子集、绝不能圈到 {@code /oapi}、静态面、探活与文档。
|
||||
* <p>
|
||||
* 为什么扫全仓控制器而不是逐个点名:CAS 的 url-pattern 被刻意收窄到
|
||||
* {@code /api/internal/admin/*}(为把机器面 {@code routes/refresh} 留在面外),
|
||||
* 于是「新加一个 {@code /api/xxx} 控制器」不再天然受保护。本测试把这件事从人工 review
|
||||
* 变成测试期失败——新映射要么落在治理面前缀内,要么必须先在这里登记自己的鉴权面。
|
||||
*/
|
||||
class SsoSurfaceGuardTest {
|
||||
|
||||
/** 机器面:不进 CAS 面,由内部令牌门(Stage 2)负责 */
|
||||
private static final Set<String> MACHINE_PATHS =
|
||||
new TreeSet<>(Collections.singletonList("/api/internal/routes/refresh"));
|
||||
|
||||
/** 必须留在鉴权面之外的路径(本次线上探针实测过的形态) */
|
||||
private static final String[] MUST_STAY_OUTSIDE = {
|
||||
"/notice/oapi/x",
|
||||
"/userbase/oapi/app/list",
|
||||
"/userclass/oapi/x",
|
||||
"/oapi/x",
|
||||
"/front/index.html",
|
||||
"/open-gateway/front/index.html",
|
||||
"/actuator/health",
|
||||
"/v3/api-docs",
|
||||
"/swagger-ui/index.html",
|
||||
"/swagger-ui.html",
|
||||
"/upload/file/a.png",
|
||||
};
|
||||
|
||||
private final SsoProperties properties = new SsoProperties();
|
||||
|
||||
/**
|
||||
* servlet 过滤器映射口径(Tomcat):{@code /prefix/*} 是<b>前缀</b>映射,匹配任意层数;
|
||||
* 与 Spring {@code AntPathMatcher}({@code *} 只吃一段)不同——按 Ant 断言会误判
|
||||
* {@code /api/internal/admin/apps/{id}} 不在面内。
|
||||
*/
|
||||
private static boolean matched(String pattern, String path) {
|
||||
if (pattern.endsWith("*")) {
|
||||
return path.startsWith(pattern.substring(0, pattern.length() - 1));
|
||||
}
|
||||
return pattern.equals(path);
|
||||
}
|
||||
|
||||
private static String join(String base, String path) {
|
||||
String head = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
|
||||
String tail = path.isEmpty() || path.startsWith("/") ? path : "/" + path;
|
||||
String joined = head + tail;
|
||||
return joined.isEmpty() ? "/" : joined;
|
||||
}
|
||||
|
||||
private static Set<String> valuesOf(Annotation mapping) {
|
||||
if (mapping == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
String[] direct;
|
||||
String[] alias;
|
||||
if (mapping instanceof RequestMapping) {
|
||||
direct = ((RequestMapping) mapping).value();
|
||||
alias = ((RequestMapping) mapping).path();
|
||||
} else if (mapping instanceof GetMapping) {
|
||||
direct = ((GetMapping) mapping).value();
|
||||
alias = ((GetMapping) mapping).path();
|
||||
} else if (mapping instanceof PutMapping) {
|
||||
direct = ((PutMapping) mapping).value();
|
||||
alias = ((PutMapping) mapping).path();
|
||||
} else if (mapping instanceof PostMapping) {
|
||||
direct = ((PostMapping) mapping).value();
|
||||
alias = ((PostMapping) mapping).path();
|
||||
} else if (mapping instanceof DeleteMapping) {
|
||||
direct = ((DeleteMapping) mapping).value();
|
||||
alias = ((DeleteMapping) mapping).path();
|
||||
} else {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
String[] picked = direct.length > 0 ? direct : alias;
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
if (picked.length == 0) {
|
||||
out.add("");
|
||||
} else {
|
||||
Collections.addAll(out, picked);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 全仓 @RestController 的「类前缀 + 方法路径」全集(无映射注解的方法不参与) */
|
||||
private Set<String> allControllerPaths() throws Exception {
|
||||
ClassPathScanningCandidateComponentProvider scanner =
|
||||
new ClassPathScanningCandidateComponentProvider(false);
|
||||
scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class));
|
||||
Set<String> paths = new LinkedHashSet<>();
|
||||
for (BeanDefinition candidate : scanner.findCandidateComponents("com.yunhai.platform.gateway")) {
|
||||
Class<?> type = Class.forName(candidate.getBeanClassName());
|
||||
Set<String> bases = valuesOf(type.getAnnotation(RequestMapping.class));
|
||||
if (bases.isEmpty()) {
|
||||
bases = new LinkedHashSet<>(Collections.singletonList(""));
|
||||
}
|
||||
for (Method method : type.getDeclaredMethods()) {
|
||||
for (Annotation mapping : method.getAnnotations()) {
|
||||
Set<String> methodPaths = valuesOf(mapping);
|
||||
for (String base : bases) {
|
||||
for (String methodPath : methodPaths) {
|
||||
paths.add(join(base, methodPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 全仓控制器映射都已声明鉴权归属() throws Exception {
|
||||
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())
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void 治理面与机器面互不重叠() {
|
||||
for (String machine : MACHINE_PATHS) {
|
||||
assertThat(matched(properties.getUrlPattern(), machine))
|
||||
.as("机器面 %s 被 CAS 面圈住了(会导致 Projman 的内部调用被 302/401 挡掉)", machine)
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void 回跳靶点必须在CAS面内() {
|
||||
// 固定 service 指向 entry-path;落在面外的话票据校验过滤器根本不运行,登录永远建不起会话
|
||||
assertThat(matched(properties.getUrlPattern(), properties.getEntryPath())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void CAS面圈定路径是api的子集() {
|
||||
assertThat(properties.getUrlPattern()).startsWith("/api/");
|
||||
assertThat(properties.getUrlPattern()).isNotEqualTo("/api/*");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 开放面静态面探活文档一律在面外() {
|
||||
// 两道门(CAS 面 + 内部令牌门)都不许碰到这些前缀——红线 1 的正面断言
|
||||
String[] patterns = {properties.getUrlPattern(), InternalTokenFilter.URL_PATTERN};
|
||||
for (String path : MUST_STAY_OUTSIDE) {
|
||||
for (String pattern : patterns) {
|
||||
assertThat(matched(pattern, path))
|
||||
.as("门模式 %s 圈到了 %s(会打断开放面/静态面)", pattern, path)
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void 机器面必须被令牌门圈住且不被CAS面圈住() {
|
||||
for (String machine : MACHINE_PATHS) {
|
||||
assertThat(matched(InternalTokenFilter.URL_PATTERN, machine))
|
||||
.as("机器面 %s 落在令牌门之外,等于裸奔", machine).isTrue();
|
||||
assertThat(machine).doesNotStartWith(InternalTokenFilter.BROWSER_SURFACE_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新增端点路径与配置字面一致() throws Exception {
|
||||
// 注解里的路径是编译期字面量、配置里的 entry-path 可被覆盖:两者一旦漂移,回跳 404 且启动无报错
|
||||
Set<String> paths = allControllerPaths();
|
||||
assertThat(paths).contains(properties.getEntryPath());
|
||||
assertThat(paths).contains("/api/internal/admin/session");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user