Просмотр исходного кода

test(S-cfc-242): supplier.nutrition.supply TDD-RED 契约测试(13红/13)——食谱供给宿主/格式校验/XSS合规/商户隔离 契约待落地

Sisyphus 2 часов назад
Родитель
Сommit
3abd6b8f07

+ 348 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierNutritionSupplyContractTest.java

@@ -0,0 +1,348 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminFoodController;
+import com.etotem.cfc.controller.admin.AdminRecipeController;
+import com.etotem.cfc.controller.admin.ReportParseAdminController;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.Recipe;
+import com.etotem.cfc.service.FoodService;
+import com.etotem.cfc.service.RecipeService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 契约级单测:商品服务商「营养/食谱数据供给」(能力键 {@code supplier.nutrition.supply},SC-S-10)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-10(营养/食谱数据供给)——
+ *       目标:供食谱数据;流程:上架食谱 → 同步;边界:<b>数据合规;格式校验</b>;模块:食谱库。
+ *       关联 SC-S-09(报告解析服务接入/对接解析中台)为本能力的上游供给链路。
+ *       用例:{@code tests/test-cases/vendor.md} TC-S-10 / TC-S-10-E(格式非法 400 / XSS 转义 /
+ *       非本商户覆盖 403)/ TC-S-10-P(跨商户访问 403)/ TC-S-N06(XSS 不执行)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.11(饮食推荐)/ §4.13(管理员接口);
+ *       契约载体 {@code AdminRecipeController}(/api/admin/recipes)+ {@code AdminFoodController}
+ *       (/api/admin/foods)+ {@code ReportParseAdminController}(/api/admin/report-parse,
+ *       SC-S-09 中台链路 backfill-food 回填食材)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b>(SC-S-10 正常流 + 边界)</p>
+ * <ul>
+ *   <li><b>正常流·供给入口</b>:服务商「上架食谱 → 同步 → 供食谱数据」——需存在供给宿主服务
+ *       {@code RecipeSupplyService}(含 {@code syncFromSupplier} 同步供给入口)与食材/食谱的
+ *       商户归属载体 {@code Recipe.supplierId} / schema 的 {@code supplier_id};</li>
+ *   <li><b>边界流·格式校验</b>:上架格式非法食谱(name 缺失 / ingredients JSON 残缺)与非法食材
+ *       (负营养值)应被拒绝(400),不落库;</li>
+ *   <li><b>边界流·数据合规(XSS)</b>:上架食谱/食材含 {@code <script>} 描述/名称应转义存储,
+ *       不得以原始脚本透传落库(TC-S-N06:内容转义存储/渲染,不执行);</li>
+ *   <li><b>权限流·商户隔离</b>:更新/删除食谱须校验商户归属,无合法商户上下文的写操作应 403
+ *       (TC-S-10-E-3 / TC-S-10-P:非本商户 403);报告解析中台(SC-S-09)回填的食材
+ *       (backfill-food)应经 {@code FoodService} 合规供给通道写入,而非绕过供给直写。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(src/main 核实)</b>:本分支 src/main 中 {@code RecipeSupplyService} 类、
+ * {@code Recipe.supplierId} 字段、schema.sql 的 {@code recipes} 建表(仅 DatabaseInitializer 有
+ * 运行时建表)均缺失;{@code AdminRecipeController.create/update/delete} 与
+ * {@code AdminFoodController.create} 为直通 CRUD,无格式校验、无 XSS 转义、无商户归属校验;
+ * {@code ReportParseAdminController} 无 {@code foodService} 供给写入点。因此本测试以
+ * 「反射结构契约 + Mockito 行为契约」探测实现缺失:能编译通过、能运行、断言失败(红),
+ * 且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code RecipeSupplyService}(含 syncFromSupplier 入口)、
+ * 给 {@code Recipe} 增加 {@code supplierId} 并在 schema.sql 落 {@code recipes} 建表 +
+ * {@code supplier_id} 列,在 {@code AdminRecipeController}(create/update/delete)与
+ * {@code AdminFoodController.create} 补格式校验(Result.badRequest→400)与 XSS 转义(存储前
+ * 清洗)与商户归属守卫(Result.forbidden→403),并让 {@code ReportParseAdminController}
+ * backfill-food 经 {@code FoodService} 合规写入后,本测试自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 JUnit5 + Mockito / 反射,无 Spring / 无 DB / 无外部服务,
+ * 与同批 RED 契约测试(TeamProfitHookContractTest / TeamStarMultiplierContractTest /
+ * ReportParserProviderConnectContractTest)同风格。</p>
+ */
+class SupplierNutritionSupplyContractTest {
+
+    /** 契约供给宿主(未实现)。 */
+    private static final String SUPPLY_HOST = "com.etotem.cfc.service.RecipeSupplyService";
+
+    private AdminRecipeController recipeController;
+    private AdminFoodController foodController;
+    private RecipeService recipeService;
+    private FoodService foodService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        recipeController = new AdminRecipeController();
+        injectMocks(recipeController);
+        recipeService = readField(recipeController, "recipeService");
+
+        foodController = new AdminFoodController();
+        injectMocks(foodController);
+        foodService = readField(foodController, "foodService");
+    }
+
+    // ==================== 正常流:供给宿主与归属载体(结构契约) ====================
+
+    @Test
+    @DisplayName("正常流-1:RecipeSupplyService 供给宿主类应存在(服务商上架食谱→同步供给)")
+    void supplyHost_classExists() {
+        assertNotNull(loadClass(SUPPLY_HOST),
+                "[supplier.nutrition.supply] RecipeSupplyService 类不存在:SC-S-10 要求服务商"
+                        + "「上架食谱 → 同步 → 供食谱数据」的供给宿主服务。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:RecipeSupplyService 应提供 syncFromSupplier 同步供给入口")
+    void supplyHost_hasSyncFromSupplierEntry() {
+        Class<?> clazz = loadClass(SUPPLY_HOST);
+        if (clazz == null) {
+            fail("[supplier.nutrition.supply] RecipeSupplyService 类不存在,无法验证同步供给入口"
+                    + "(SC-S-10 正常流:上架食谱 → 同步)。");
+        }
+        boolean hasSync = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("syncFromSupplier")) {
+                hasSync = true;
+                break;
+            }
+        }
+        assertTrue(hasSync,
+                "[supplier.nutrition.supply] RecipeSupplyService 应提供 syncFromSupplier(...) "
+                        + "方法作为服务商上架食谱→同步供给的入口(SC-S-10)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:Recipe 实体应含 supplierId 商户归属字段(商户隔离/供给归属载体)")
+    void recipe_hasSupplierIdOwnershipField() {
+        boolean hasSupplierId = false;
+        for (Field f : Recipe.class.getDeclaredFields()) {
+            if (f.getName().equals("supplierId")) {
+                hasSupplierId = true;
+                break;
+            }
+        }
+        assertTrue(hasSupplierId,
+                "[supplier.nutrition.supply] Recipe 实体应含 supplierId 字段:SC-S-10 商户供给链路的"
+                        + "商户归属/跨商户隔离(TC-S-10-P 非本商户 403)需要归属载体。当前缺失。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:schema.sql 应落地 recipes 建表(当前仅 DatabaseInitializer 运行时建表)")
+    void schemaSql_hasRecipesTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("recipes"),
+                "[supplier.nutrition.supply] schema.sql 缺少 recipes 建表:SC-S-10 食谱库供给"
+                        + "应含完整 DDL 快照(当前仅 DatabaseInitializer 运行时建表,schema.sql 无镜像)。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:schema.sql recipes 建表块应含 supplier_id 列(商户隔离存储载体)")
+    void schemaSql_recipesHasSupplierIdColumn() {
+        String schema = readSchemaSql();
+        int start = schema.indexOf("CREATE TABLE IF NOT EXISTS recipes");
+        if (start < 0) {
+            fail("[supplier.nutrition.supply] schema.sql 缺少 recipes 建表,无法校验 supplier_id 列"
+                    + "(SC-S-10 食谱库供给 DDL 快照缺失)。");
+        }
+        int end = schema.indexOf(");", start);
+        String block = end > start && end < schema.length()
+                ? schema.substring(start, end)
+                : schema.substring(start);
+        assertTrue(block.contains("supplier_id"),
+                "[supplier.nutrition.supply] schema.sql recipes 建表块缺少 supplier_id 列:食谱库"
+                        + "供给需按商户归属存储,支撑跨商户隔离(TC-S-10-P 403)。当前缺失。");
+    }
+
+    // ==================== 边界流:格式校验(行为契约,400) ====================
+
+    @Test
+    @DisplayName("边界流-1:上架残缺食谱(name 缺失)应格式校验拒绝 400")
+    void recipeController_create_rejectsEmptyName() {
+        Recipe recipe = new Recipe();
+        recipe.setName("");
+        recipe.setMealType("lunch");
+
+        Result<String> result = recipeController.create(recipe);
+
+        assertEquals(400, result.getCode(),
+                "[supplier.nutrition.supply] 上架 name 为空的食谱应被格式校验拒绝(TC-S-10-E-1 格式校验 400)"
+                        + ",当前直通落库返回 200,契约缺失。");
+        verify(recipeService, never()).create(any(Recipe.class));
+    }
+
+    @Test
+    @DisplayName("边界流-2:上架 ingredients 为残缺 JSON 的食谱应格式校验拒绝 400")
+    void recipeController_create_rejectsBrokenIngredientsJson() {
+        Recipe recipe = new Recipe();
+        recipe.setName("乱炖营养餐");
+        recipe.setIngredients("["); // 残缺 JSON
+
+        Result<String> result = recipeController.create(recipe);
+
+        assertEquals(400, result.getCode(),
+                "[supplier.nutrition.supply] 上架 ingredients 为残缺 JSON 的食谱应格式校验拒绝"
+                        + "(TC-S-10-E-1 格式校验 400),当前直通落库返回 200,契约缺失。");
+        verify(recipeService, never()).create(any(Recipe.class));
+    }
+
+    @Test
+    @DisplayName("边界流-3:上架含负营养值的食材应数据合规拒绝 400")
+    void foodController_create_rejectsNegativeNutrients() {
+        Food food = new Food();
+        food.setName("问题食材");
+        food.setCalories(new BigDecimal("-100"));
+
+        Result<Food> result = foodController.create(food);
+
+        assertEquals(400, result.getCode(),
+                "[supplier.nutrition.supply] 上架负热量食材应被数据合规校验拒绝(SC-S-10 边界:数据合规),"
+                        + "当前直通落库返回 200,契约缺失。");
+        verify(foodService, never()).create(any(Food.class));
+    }
+
+    // ==================== 边界流:数据合规(XSS 转义存储,不执行) ====================
+
+    @Test
+    @DisplayName("边界流-4:上架含 <script> 描述的食谱应转义存储(不执行)")
+    void recipeController_create_sanitizesXssDescription() {
+        Recipe recipe = new Recipe();
+        recipe.setName("合规早餐");
+        recipe.setDescription("<script>alert('xss')</script>营养建议");
+
+        recipeController.create(recipe);
+
+        ArgumentCaptor<Recipe> captor = ArgumentCaptor.forClass(Recipe.class);
+        verify(recipeService).create(captor.capture());
+        String storedDesc = captor.getValue().getDescription();
+        assertFalse(storedDesc != null && storedDesc.contains("<script>"),
+                "[supplier.nutrition.supply] 含 <script> 描述的食谱应转义存储(TC-S-10-E-2 / TC-S-N06:"
+                        + "XSS 转义存储不执行),实际落库描述仍含原始脚本:" + storedDesc);
+    }
+
+    @Test
+    @DisplayName("边界流-5:上架含 <script> 名称的食材应转义存储(不执行)")
+    void foodController_create_sanitizesXssName() {
+        Food food = new Food();
+        food.setName("<script>alert('xss')</script>");
+
+        foodController.create(food);
+
+        ArgumentCaptor<Food> captor = ArgumentCaptor.forClass(Food.class);
+        verify(foodService).create(captor.capture());
+        String storedName = captor.getValue().getName();
+        assertFalse(storedName != null && storedName.contains("<script>"),
+                "[supplier.nutrition.supply] 含 <script> 名称的食材应转义存储(TC-S-N06:内容转义存储"
+                        + "不执行),实际落库名称仍含原始脚本:" + storedName);
+    }
+
+    // ==================== 权限流:商户隔离(非本商户 403) ====================
+
+    @Test
+    @DisplayName("权限流-1:更新食谱应在无合法商户上下文时拒绝 403(跨商户覆盖防护)")
+    void recipeController_update_requiresOwnershipGuard() {
+        Recipe recipe = new Recipe();
+        recipe.setId(1L);
+        recipe.setName("被篡改食谱");
+
+        Result<String> result = recipeController.update(recipe);
+
+        assertEquals(403, result.getCode(),
+                "[supplier.nutrition.supply] 更新食谱应校验商户归属,无合法商户上下文应 403"
+                        + "(TC-S-10-E-3 非本商户覆盖 403 / TC-S-10-P 跨商户访问 403),"
+                        + "当前直通更新返回 200,契约缺失。");
+        verify(recipeService, never()).update(any(Recipe.class));
+    }
+
+    @Test
+    @DisplayName("权限流-2:删除食谱应在无合法商户上下文时拒绝 403(跨商户删除防护)")
+    void recipeController_delete_requiresOwnershipGuard() {
+        java.util.Map<String, Object> params = new java.util.HashMap<>();
+        params.put("id", 1L);
+
+        Result<String> result = recipeController.delete(params);
+
+        assertEquals(403, result.getCode(),
+                "[supplier.nutrition.supply] 删除食谱应校验商户归属,无合法商户上下文应 403"
+                        + "(TC-S-10-P 跨商户资源隔离),当前直通删除返回 200,契约缺失。");
+        verify(recipeService, never()).delete(any(Long.class));
+    }
+
+    @Test
+    @DisplayName("权限流-3:报告解析中台(SC-S-09)回填食材应经 FoodService 合规供给写入")
+    void reportParseAdminController_backfillFood_delegatesCompliantSupplyWrite() {
+        boolean hasFoodService = false;
+        for (Field f : ReportParseAdminController.class.getDeclaredFields()) {
+            if (FoodService.class.equals(f.getType())) {
+                hasFoodService = true;
+                break;
+            }
+        }
+        assertTrue(hasFoodService,
+                "[supplier.nutrition.supply] ReportParseAdminController.backfill-food(SC-S-09 中台回填"
+                        + "食材 → 营养供给)应经 FoodService 合规供给通道写入(数据合规边界),当前仅经"
+                        + "reportCollectService 直写,无合规供给写入点。");
+    }
+
+    // ==================== 反射 / Mockito 辅助 ====================
+
+    private static <T> T readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        T value = (T) f.get(target);
+        return value;
+    }
+
+    /** 将所有非静态非常量字段注入 Mock(控制器无参构造 + 反射注入,无 Spring)。 */
+    private static void injectMocks(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
+                continue;
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}