init local code
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user