对接规范§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:
@@ -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