From 9ba188b4cbac6fcb95ef9c7c97fa084bb33ca98f Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 21 Aug 2026 02:49:47 -0400 Subject: [PATCH] fix(wiki): reject reserved page types (#601) --- .../profile/WikiPageTypeProfileService.java | 61 ++++++++++++++++--- .../WikiPageTypeProfileServiceE2ETest.java | 22 +++++++ .../WikiPageTypeProfileServiceTest.java | 16 +++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java index a0592eaa..f9c4f550 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java @@ -33,6 +33,13 @@ public class WikiPageTypeProfileService { private final WikiPageTypeProfileMapper profileMapper; private final ObjectMapper objectMapper; + /** + * Reserved page types are internal control-plane types. User profiles must + * never route business pages into them, because downstream list/search and + * cleanup paths intentionally treat them specially. + */ + private static final Set RESERVED_PAGE_TYPES = Set.of("system", "synthesis"); + /** Parsed once at startup; immutable thereafter. */ private WikiPageTypeProfile defaultProfile; @@ -209,10 +216,9 @@ public class WikiPageTypeProfileService { * @throws IllegalArgumentException when {@code configJson} does not parse */ public void saveProfile(Long kbId, String name, String configJson) { - try { - objectMapper.readValue(configJson, WikiPageTypeProfile.class); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid profile config JSON: " + e.getMessage()); + java.util.List issues = validateProfileJson(configJson); + if (!issues.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", issues)); } WikiPageTypeProfileEntity existing = findEnabledRow(kbId); if (existing != null) { @@ -262,6 +268,16 @@ public class WikiPageTypeProfileService { issues.add("Profile declares no pageTypes"); return issues; } + profile.getPageTypes().keySet().forEach(typeName -> { + String normalized = normalizeTypeName(typeName); + if (RESERVED_PAGE_TYPES.contains(normalized)) { + issues.add(typeName + ": reserved pageType is not allowed in user profiles"); + } + }); + String fallback = normalizeTypeName(profile.getFallbackType()); + if (RESERVED_PAGE_TYPES.contains(fallback)) { + issues.add("reserved fallbackType is not allowed: " + fallback); + } java.util.Set validTypes = java.util.Set.of( "string", "number", "boolean", "date", "enum", "string_array"); profile.getPageTypes().forEach((typeName, def) -> { @@ -283,15 +299,40 @@ public class WikiPageTypeProfileService { /** * Normalise a routed/created pageType against the KB profile: a declared - * type is returned as-is (lowercase); an unknown type is downgraded to the - * profile's {@code fallbackType}. Never returns null. + * type is returned as-is (lowercase); an unknown or reserved type is + * downgraded to a safe fallback. Never returns null. */ public String normalizePageType(Long kbId, String pageType) { WikiPageTypeProfile profile = resolveProfile(kbId); - if (pageType != null && profile.hasPageType(pageType)) { - return pageType.trim().toLowerCase(); + String normalized = normalizeTypeName(pageType); + if (normalized != null && !RESERVED_PAGE_TYPES.contains(normalized) + && profile.hasPageType(normalized)) { + return normalized; } - String fallback = profile.getFallbackType(); - return fallback == null ? "concept" : fallback.trim().toLowerCase(); + return safeFallbackType(profile); + } + + private static String safeFallbackType(WikiPageTypeProfile profile) { + String fallback = profile == null ? null : normalizeTypeName(profile.getFallbackType()); + if (profile != null && fallback != null && !RESERVED_PAGE_TYPES.contains(fallback) + && profile.hasPageType(fallback)) { + return fallback; + } + if (profile != null && profile.hasPageType("concept")) { + return "concept"; + } + return profile == null ? "concept" : profile.getPageTypes().keySet().stream() + .map(WikiPageTypeProfileService::normalizeTypeName) + .filter(type -> type != null && !RESERVED_PAGE_TYPES.contains(type)) + .findFirst() + .orElse("concept"); + } + + private static String normalizeTypeName(String type) { + if (type == null) { + return null; + } + String normalized = type.trim().toLowerCase(); + return normalized.isEmpty() ? null : normalized; } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java index 51a97209..4ff1903d 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java @@ -88,6 +88,17 @@ class WikiPageTypeProfileServiceE2ETest { } } + @Test + void reservedPageType_isRejectedOnSave() { + try { + service.saveProfile(SEQ.incrementAndGet(), "bad", + "{\"pageTypes\":{\"system\":{\"label\":\"System\"},\"concept\":{\"label\":\"Concept\"}}}"); + org.junit.jupiter.api.Assertions.fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("reserved pageType")); + } + } + @Test void validateProfileJson_reportsIssues() { assertTrue(service.validateProfileJson(EPISODE_JSON).isEmpty()); @@ -103,4 +114,15 @@ class WikiPageTypeProfileServiceE2ETest { "{\"pageTypes\":{\"x\":{\"schema\":{\"f\":{\"type\":\"banana\"}}}}}"); assertTrue(badType.stream().anyMatch(s -> s.contains("unknown field type"))); } + + @Test + void validateProfileJson_rejectsReservedPageTypesAndFallback() { + List reservedType = service.validateProfileJson( + "{\"pageTypes\":{\"system\":{\"label\":\"System\"},\"concept\":{\"label\":\"Concept\"}}}"); + assertTrue(reservedType.stream().anyMatch(s -> s.contains("reserved pageType"))); + + List reservedFallback = service.validateProfileJson( + "{\"fallbackType\":\"synthesis\",\"pageTypes\":{\"concept\":{\"label\":\"Concept\"}}}"); + assertTrue(reservedFallback.stream().anyMatch(s -> s.contains("reserved fallbackType"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java index 2bebde45..ad49291f 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java @@ -81,6 +81,22 @@ class WikiPageTypeProfileServiceTest { assertEquals("concept", service.normalizePageType(1L, null)); } + @Test + void normalizePageType_rejectsReservedProfileTypesEvenWhenPersisted() { + WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity(); + row.setKbId(1L); + row.setEnabled(1); + row.setConfigJson("{\"fallbackType\":\"synthesis\",\"pageTypes\":{" + + "\"system\":{\"label\":\"System\"}," + + "\"synthesis\":{\"label\":\"Synthesis\"}," + + "\"concept\":{\"label\":\"Concept\"}}}"); + when(mapper.selectOne(any())).thenReturn(row); + + assertEquals("concept", service.normalizePageType(1L, "system")); + assertEquals("concept", service.normalizePageType(1L, "synthesis")); + assertEquals("concept", service.normalizePageType(1L, "unknown")); + } + @Test void describeForPrompt_defaultProfile_listsBuiltInTypes() { when(mapper.selectOne(any())).thenReturn(null);