对接规范§3:外观偏好同源读端点 /api/appearance(自建同构哑管道,503铁律,回环验签链 userbase→userhome,env OPENGW_APPEARANCE_APP_KEY 点燃)
build / build-push (push) Successful in 2m11s

This commit is contained in:
chenxing
2026-10-11 21:58:54 +08:00
parent 7e3690e523
commit a2c8905c7a
5 changed files with 397 additions and 3 deletions
@@ -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();
}
}