init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -0,0 +1,194 @@
package com.yunhai.cms.theme;
import com.yunhai.cms.controller.api.ThemePreviewController;
import com.yunhai.cms.controller.portal.PortalPageRenderer;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 主题预览接口(方案 §8.4 P1 矩阵 T23-T27,§5.5)。
* 控制器级 standalone MockMvc + 真实 Thymeleaf 视图解析器(不走 CAS 过滤器链,§8.6 分层)。
* B-6 后 preview 需 BoaUser + 可管理校验:standalone 用参数解析器注入超管(负例直调控制器)。
*/
@DisplayName("PreviewApi - /api/theme/preview 预览(方案 §5.5)")
class PreviewApiIT extends CmsIntegrationTestBase {
@Autowired
private ThemeService themeService;
@Autowired
private PortalQueryService portalQueryService;
@Autowired
private PortalPageRenderer pageRenderer;
@Autowired
private SiteService siteService;
private MockMvc standalone;
private ThemePreviewController controller;
@BeforeEach
void seed() {
purgeTables();
com.yunhai.cms.entity.CmsSite site = persistSite("demo", 1, "预览内容源门户");
Integer categoryId = persistCategory(site.getId(), 0, "新闻中心",
com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
persistArticle(site.getId(), categoryId, "预览测试文章",
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60));
controller = new ThemePreviewController(themeService, portalQueryService, pageRenderer, siteService);
standalone = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return com.yunhai.platform.utils.model.BoaUser.class
.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
return TestUsers.superAdmin();
}
})
.setViewResolvers(thymeleafViewResolverForStandalone())
.build();
}
/** standalone 环境专用视图解析器:真上下文的 resolver 已初始化,不能复用(Cannot reinitialize) */
private org.thymeleaf.spring5.view.ThymeleafViewResolver thymeleafViewResolverForStandalone() {
org.thymeleaf.spring5.SpringTemplateEngine engine = new org.thymeleaf.spring5.SpringTemplateEngine();
org.thymeleaf.templateresolver.ITemplateResolver templateResolver =
new org.thymeleaf.templateresolver.ClassLoaderTemplateResolver() {
{
setPrefix("/templates/");
setSuffix(".html");
setCharacterEncoding("UTF-8");
}
};
engine.setTemplateResolver(templateResolver);
// Boot 自动装配会注册 #temporals 方言,standalone 引擎需手动补齐
engine.addDialect(new org.thymeleaf.extras.java8time.dialect.Java8TimeDialect());
org.thymeleaf.spring5.view.ThymeleafViewResolver viewResolver =
new org.thymeleaf.spring5.view.ThymeleafViewResolver();
viewResolver.setTemplateEngine(engine);
viewResolver.setCharacterEncoding("UTF-8");
return viewResolver;
}
@AfterEach
void clean() {
purgeTables();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
@Test
@DisplayName("T23: 预览按指定主题渲染(含站点名与主题标记)")
void previewRendersSpecifiedTheme() throws Exception {
MvcResult result = standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "t-demo-red"))
.andExpect(status().isOk()).andReturn();
String html = body(result);
assertThat(html).contains("预览内容源门户");
assertThat(html).as("资产经主题代理即主题标记").contains("t-demo-red");
}
@Test
@DisplayName("T24: 预览注入 p_ 参数(p_primary=#b01a2e → --primary:#b01a2e)")
void previewInjectsParams() throws Exception {
MvcResult result = standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "t-demo-red")
.param("p_primary", "#b01a2e"))
.andExpect(status().isOk()).andReturn();
assertThat(body(result)).contains("--primary:#b01a2e");
}
@Test
@DisplayName("T25: 预览资产指向管理端代理(R1 串包修复的核心断言)")
void previewAssetsPointToAdminProxy() throws Exception {
MvcResult result = standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "t-demo-red"))
.andExpect(status().isOk()).andReturn();
assertThat(body(result)).contains("/api/theme/t-demo-red/asset/css/main.css");
}
@Test
@DisplayName("T26: 预览响应 no-store(不进缓存/CDN)")
void previewNoStore() throws Exception {
standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "default"))
.andExpect(header().string("Cache-Control", "no-store"));
}
@Test
@DisplayName("T27: 未注册主题 PARAM_ERROR(明确告知,不静默回退)")
void previewUnregisteredThemeRejected() {
assertThatThrownBy(() -> themeService.requireRegistered("no-such"))
.isInstanceOf(BizException.class)
.hasMessageContaining("皮肤包未注册");
}
// ===== B-6 负例(page 白名单 + 可管理校验)=====
@Test
@DisplayName("B-6①:page 非 home 白名单值报 PARAM_ERROR,不静默按 home 渲染(T-5)")
void previewRejectsNonHomePage() {
assertThatThrownBy(() -> controller.preview("demo", "t-demo-red", "list",
TestUsers.superAdmin(),
new org.springframework.mock.web.MockHttpServletRequest(),
new org.springframework.mock.web.MockHttpServletResponse(),
new org.springframework.validation.support.BindingAwareModelMap()))
.isInstanceOf(BizException.class)
.hasMessageContaining("不支持的预览页面");
}
@Test
@DisplayName("B-6②:不可管理内容源站点的用户预览被拒 FORBIDDEN(T-5)")
void previewRejectsUnmanageableUser() {
// 内容源站点 schId=1,学校2 用户不可管理
assertThatThrownBy(() -> controller.preview("demo", "t-demo-red", "home",
TestUsers.schoolUser(2),
new org.springframework.mock.web.MockHttpServletRequest(),
new org.springframework.mock.web.MockHttpServletResponse(),
new org.springframework.validation.support.BindingAwareModelMap()))
.isInstanceOf(BizException.class)
.hasMessageContaining("无权");
}
@Test
@DisplayName("B-6②:未登录(无 BoaUser)预览被拒(standalone 无解析器时参数为 null)")
void previewRejectsMissingUser() {
assertThatThrownBy(() -> controller.preview("demo", "t-demo-red", "home",
null,
new org.springframework.mock.web.MockHttpServletRequest(),
new org.springframework.mock.web.MockHttpServletResponse(),
new org.springframework.validation.support.BindingAwareModelMap()))
.isInstanceOf(BizException.class)
.hasMessageContaining("未登录");
}
}
@@ -0,0 +1,112 @@
package com.yunhai.cms.theme;
import com.yunhai.cms.controller.api.ThemeController;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 主题管理接口契约(方案 §8.3 E 组 T18-T21,§5.2)。
* 测试分层(§8.6):T18 全上下文 MockMvc(匿名 CAS 302 契约,现有唯一先例模式);
* T19 服务级直调;T20/T21 standalone MockMvc(不走 CAS 过滤器链的控制器薄契约)。
*/
@DisplayName("ThemeApi - /api/theme 接口契约(方案 §5.2)")
class ThemeApiIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private ThemeService themeService;
@Autowired
private ThemeRegistry themeRegistry;
private MockMvc standalone;
@BeforeEach
void setUp() {
purgeTables();
standalone = MockMvcBuilders
.standaloneSetup(new ThemeController(themeService, themeRegistry)).build();
}
@AfterEach
void clean() {
purgeTables();
}
@Test
@DisplayName("T18: /api/theme/list 匿名 302 至 SSO(CAS 契约,同 /api/site 先例)")
void themeList_anonymousRedirectsToSso() throws Exception {
mockMvc.perform(get("/api/theme/list"))
.andExpect(status().is3xxRedirection())
.andExpect(result -> assertThat(result.getResponse().getRedirectedUrl())
.contains("sso."));
}
@Test
@DisplayName("T19: 主题列表返回 schema(含 default:name/title/params/bannerSize/screenshotUrl)")
void themeList_containsDefaultWithSchema() {
List<com.yunhai.cms.model.vo.ThemeVo> list = themeService.list();
assertThat(list).as("至少包含 default(Registry 已注册)")
.extracting(vo -> vo.getName()).contains("default");
com.yunhai.cms.model.vo.ThemeVo def = list.stream()
.filter(v -> "default".equals(v.getName()))
.findFirst().orElseThrow(IllegalStateException::new);
assertThat(def.getTitle()).isNotBlank();
assertThat(def.getBannerSize()).isNotBlank();
assertThat(def.getScreenshotUrl()).isEqualTo("/api/theme/default/asset/screenshot.png");
assertThat(def.getParams()).isNotEmpty();
com.yunhai.cms.model.vo.ThemeVo.ThemeParamVo primary = def.getParams().stream()
.filter(p -> "primary".equals(p.getKey()))
.findFirst().orElseThrow(IllegalStateException::new);
assertThat(primary.getType()).isEqualTo("color");
assertThat(primary.getCssVar()).isEqualTo("--primary");
assertThat(primary.getDefaultValue()).isNotBlank();
}
@Test
@DisplayName("T20: 资产代理防穿越(..%2f 拒绝 400/404)")
void assetTraversalRejected() throws Exception {
standalone.perform(get("/api/theme/t-demo-red/asset/..%2F..%2Fapplication.yml"))
.andExpect(result -> assertThat(result.getResponse().getStatus()).isIn(400, 404));
}
@Test
@DisplayName("T21: 未注册主题资产 404(不落目录拼接)")
void unregisteredThemeAsset404() throws Exception {
standalone.perform(get("/api/theme/no-such-theme/asset/screenshot.png"))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("T21 补充: 夹具主题 screenshot 经代理可取且带缓存头")
void fixtureScreenshotServed() throws Exception {
standalone.perform(get("/api/theme/t-demo-red/asset/screenshot.png"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=3600, public"));
}
@Test
@DisplayName("P2-T32: 三新包 screenshot 经资产代理可取(registry 校验存在 + 接口冒烟)")
void newPackageScreenshotsServed() throws Exception {
for (String name : new String[] {"classic-red", "blue-white", "warm-kinder"}) {
standalone.perform(get("/api/theme/{name}/asset/screenshot.png", name))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=3600, public"));
}
}
}
@@ -0,0 +1,155 @@
package com.yunhai.cms.theme;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* 主题注册表纯单元测试(方案 §8.3 A 组 T1-T8):直接 new,不启 Spring。
* 夹具主题在 src/test/resources/templates/themes/(t- 前缀,§8.7),
* 断言一律用"包含/不包含",禁止精确数量断言(夹具被全量 IT 拾取)。
*/
@DisplayName("ThemeRegistry - 主题包注册与校验(方案 §4.1/§5.1)")
class ThemeRegistryTest {
private ThemeRegistry registry;
@BeforeEach
void setUp() {
registry = new ThemeRegistry();
registry.load();
}
@Test
@DisplayName("T1: default 包注册成功且声明 primary 参数(color/cssVar)")
void defaultPackageRegisteredWithPrimaryParam() {
ThemeInfo info = registry.get(ThemeRegistry.DEFAULT_THEME);
assertThat(info).as("default 包必须注册(缺清单或非法则本测试红,§4.1 theme.json 必备)")
.isNotNull();
assertThat(info.getName()).isEqualTo("default");
assertThat(info.getTitle()).isNotBlank();
assertThat(info.getSpecVersion()).isEqualTo(1);
ThemeParam primary = info.param("primary");
assertThat(primary).isNotNull();
assertThat(primary.getType()).isEqualTo(ThemeParam.TYPE_COLOR);
assertThat(primary.getCssVar()).isEqualTo("--primary");
assertThat(primary.getDefaultValue()).isNotBlank();
}
@Test
@DisplayName("T2: 坏 JSON 的包被剔除(t-broken-json)")
void brokenJsonPackageRejected() {
assertThat(registry.contains("t-broken-json")).isFalse();
assertThat(registry.contains("t-demo-red"))
.as("合法夹具包必须注册成功(防止'全部剔除'假绿)").isTrue();
}
@Test
@DisplayName("T3: 缺必备模板的包被剔除(t-missing-list:有清单无 list.html)")
void missingTemplatePackageRejected() {
assertThat(registry.contains("t-missing-list")).isFalse();
assertThat(registry.contains("t-demo-red")).isTrue();
}
@Test
@DisplayName("T4: name 与目录名不一致的包被剔除(t-mismatch)")
void nameMismatchPackageRejected() {
assertThat(registry.contains("t-mismatch")).isFalse();
assertThat(registry.contains("t-mismatch-x")).isFalse();
assertThat(registry.contains("t-demo-red")).isTrue();
}
@Test
@DisplayName("T5: param key 含非法字符的包被剔除(t-bad-key)")
void badParamKeyPackageRejected() {
assertThat(registry.contains("t-bad-key")).isFalse();
assertThat(registry.contains("t-demo-red")).isTrue();
}
@Test
@DisplayName("T6: cssVar 出现在非 color 类型参数上被剔除(t-bad-cssvar)")
void cssVarOnNonColorParamRejected() {
assertThat(registry.contains("t-bad-cssvar")).isFalse();
assertThat(registry.contains("t-demo-red")).isTrue();
}
@Test
@DisplayName("T7: screenshot 声明的资源缺失被剔除(t-bad-shot)")
void missingScreenshotPackageRejected() {
assertThat(registry.contains("t-bad-shot")).isFalse();
assertThat(registry.contains("t-demo-red")).isTrue();
}
@Test
@DisplayName("T8: 列表至少包含 default(护栏用例)")
void listContainsDefault() {
assertThat(registry.list())
.extracting(ThemeInfo::getName)
.contains(ThemeRegistry.DEFAULT_THEME);
}
@Test
@DisplayName("P2-T33: 三新包参数默认值互异(防拷贝忘改的低成本哨兵)")
void newPackagesPrimaryDefaultsDiffer() {
String defaultPrimary = registry.get(ThemeRegistry.DEFAULT_THEME).param("primary").getDefaultValue();
for (String name : new String[] {"classic-red", "blue-white", "warm-kinder"}) {
ThemeParam primary = registry.get(name).param("primary");
assertThat(primary).as("%s 应声明 primary 参数", name).isNotNull();
assertThat(primary.getDefaultValue())
.as("%s 的 primary 默认值不得与 default 相同(拷贝忘改哨兵)", name)
.isNotEqualTo(defaultPrimary);
}
}
@Test
@DisplayName("default 包缺失时 fail-fast(方案 §5.1,启动失败防线)")
void defaultMissingFailsFast() {
ThemeRegistry empty = new ThemeRegistry() {
@Override
public void load() {
// 模拟扫描不到任何包:load 为空即注册表为空
}
};
assertThatCode(empty::verifyDefaultPresent)
.as("default 缺失必须抛异常阻止启动(fail-fast,§5.1)")
.isInstanceOf(IllegalStateException.class);
}
// ===== B-7:image 类型参数路径校验与应用真实路径一致(T-6)=====
@Test
@DisplayName("B-7: image 参数仅 /cms/upload/** 合法(含 context-path),旧 /upload/** 不再放行")
void imageParamValidatesAgainstContextUploadPath() {
ThemeParam image = new ThemeParam();
image.setKey("heroImage");
image.setType(ThemeParam.TYPE_IMAGE);
assertThat(image.isValidValue("/cms/upload/cms/demo/202609/a.jpg")).isTrue();
assertThat(image.isValidValue("/upload/cms/demo/a.jpg"))
.as("旧约定缺 /cms 前缀,context-path=/cms 部署下渲染必 404").isFalse();
assertThat(image.isValidValue("javascript:alert(1)")).isFalse();
assertThat(image.isValidValue("https://evil.example/x.png")).isFalse();
assertThat(image.isValidValue(null)).isFalse();
assertThat(image.isValidValue("")).isFalse();
}
@Test
@DisplayName("B-7/T-6: image 参数端到端——夹具声明 → 注册加载 → filterValidValues 按 /cms/upload/ 过滤")
void imageParamFixtureEndToEnd() {
ThemeInfo info = registry.get("t-demo-red");
ThemeParam hero = info.param("heroImage");
assertThat(hero).as("夹具包应声明 image 类型参数").isNotNull();
assertThat(hero.getType()).isEqualTo(ThemeParam.TYPE_IMAGE);
assertThat(hero.getDefaultValue()).isEqualTo("/cms/upload/cms/t-demo-red/hero.png");
assertThat(info.filterValidValues(java.util.Collections.singletonMap(
"heroImage", "/cms/upload/cms/x/202609/b.png")))
.containsKey("heroImage");
assertThat(info.filterValidValues(java.util.Collections.singletonMap(
"heroImage", "/upload/cms/x/b.png")))
.as("旧 /upload/** 值应被过滤丢弃,不落库")
.doesNotContainKey("heroImage");
}
}
@@ -0,0 +1,70 @@
package com.yunhai.cms.theme;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* themeVarsCss 构建纯函数测试(方案 §8.3 C 组 T16)。
*/
@DisplayName("ThemeVarsCss - CSS 变量覆盖串构建(方案 §5.3)")
class ThemeVarsCssTest {
private ThemeInfo schema() {
ThemeInfo info = new ThemeInfo();
info.setName("t-demo-red");
info.getParams().add(param("primary", "--primary", ThemeParam.TYPE_COLOR, "#b01a2e"));
info.getParams().add(param("bg", "--bg", ThemeParam.TYPE_COLOR, "#f7f5f2"));
info.getParams().add(param("headerStyle", null, ThemeParam.TYPE_SELECT, "banner"));
return info;
}
private ThemeParam param(String key, String cssVar, String type, String defaultValue) {
ThemeParam p = new ThemeParam();
p.setKey(key);
p.setCssVar(cssVar);
p.setType(type);
p.setDefaultValue(defaultValue);
return p;
}
@Test
@DisplayName("T16: 给定 schema+params 构建期望 :root 串;非法值剔除该变量")
void buildProducesRootBlockAndDropsInvalidValues() {
ThemeInfo schema = schema();
Map<String, String> params = new LinkedHashMap<>();
params.put("primary", "#b01a2e");
params.put("bg", "#f7f5f2");
params.put("headerStyle", "plain"); // 无 cssVar,不进 CSS
String css = ThemeVarsCss.build(schema, params);
assertThat(css).isEqualTo(":root{--primary:#b01a2e;--bg:#f7f5f2;}");
}
@Test
@DisplayName("T16: 非法色值剔除该变量,其余合法声明保留")
void buildDropsInvalidColorButKeepsValidOnes() {
ThemeInfo schema = schema();
Map<String, String> params = new LinkedHashMap<>();
params.put("primary", "red;}"); // 非法
params.put("bg", "#f7f5f2");
String css = ThemeVarsCss.build(schema, params);
assertThat(css).isEqualTo(":root{--bg:#f7f5f2;}");
assertThat(css).doesNotContain("red;}");
}
@Test
@DisplayName("T16: 空参数/全部非法时返回空串(模板 th:if 不输出 style)")
void buildReturnsEmptyWhenNoValidDeclarations() {
assertThat(ThemeVarsCss.build(schema(), new LinkedHashMap<>())).isEmpty();
Map<String, String> invalid = new LinkedHashMap<>();
invalid.put("primary", "javascript:alert(1)");
assertThat(ThemeVarsCss.build(schema(), invalid)).isEmpty();
}
}