|
|
@@ -0,0 +1,196 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:客户档案数据导出(合规)(能力键 {@code teacher.client.export},SC-T-29,TDD-RED 阶段)。
|
|
|
+ *
|
|
|
+ * <p><b>契约溯源</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-29「客户档案数据导出(合规)」——
|
|
|
+ * 规划师(teacher)导出其名下客户档案数据的机构视角批量导出;sc-key-map 台账
|
|
|
+ * {@code docs/tdd/orchestration/sc-key-map.md} 登记 SC-T-29 → 能力键 {@code teacher.client.export}(RED/未跑通);
|
|
|
+ * 用例台账 GAP-053(TC-T-29 正常流 / TC-T-29-E 异常流 / TC-T-29-P 权限流)。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}(EVD-10 契约 77026df42)
|
|
|
+ * §1 实测回填 + §4 G-3——
|
|
|
+ * 「管理端/规划师侧客户档案导出缺失:AdminOrgMemberController /api/admin/org/member/* 仅 list/add/remove/change-type,
|
|
|
+ * 无 export;现有 UserController POST /api/user/data-export 是登录用户本人的个保法数据可携带权导出
|
|
|
+ * ({@code AccountCleanupService.exportUserData}),不覆盖「规划师导出其名下客户档案」的管理员场景」。
|
|
|
+ * 建议处置:后端新增导出入口(可复用 accountCleanupService 或导出 CSV)。</li>
|
|
|
+ * <li>宿主契约(本 RED 测试定稿,派发单设计锚点):{@code com.etotem.cfc.controller.FamilyExportController}
|
|
|
+ * ——「客户档案数据导出」设计目标控制器。origin/cfclub @ e83c92b84 实测 <b>不存在</b>(实现未落地),
|
|
|
+ * 测试以反射结构契约探测缺失,失败断言原文红色清晰可贴。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code Result<Map> exportClientArchive(Map<String,Object> params)}(或
|
|
|
+ * {@code Result<Map> exportClientArchive(Long scopeMemberId)})——客户档案导出入口:
|
|
|
+ * 导出范围<u>显式入参</u>(仅限绑定客户;越授权可拒 / 空范围返回错误),
|
|
|
+ * 统一返回 {@code Result} 包装(失败不裸抛异常)。参数按仓库 Controller 惯例可追加
|
|
|
+ * {@code userId/role} 等角色上下文参数。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>三类流覆盖(GAP-053)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>正常流(TC-T-29)</b>:设计宿主控制器存在,提供客户档案导出入口(导出范围入参 ≥1);</li>
|
|
|
+ * <li><b>边界/异常流(TC-T-29-E)</b>:导出范围显式入参(Map 或数值 ID 载体,越授权可拒/空范围错误返回),
|
|
|
+ * 返回统一 {@code Result} 包装(失败 {@code Result.error},不裸抛);注意 {@code children} 表生产
|
|
|
+ * 缺 {@code phone/id_card} 两列的历史约束(根 AGENTS.md「children 表标注冲突」),导出全字段 SELECT
|
|
|
+ * 需按现状经 {@code family_members} 聚合处理,不得依赖 {@code children} 全字段快照;</li>
|
|
|
+ * <li><b>权限流(TC-T-29-P)</b>:teacher 角色守卫——宿主注入全项目唯一授权判定点
|
|
|
+ * {@code GuideFamilyAccessGuard}({@code guide_families.status='binding'} isBound 授权源,SC-T-13 同源)
|
|
|
+ * 或导出方法携带角色上下文参数(Controller 惯例 {@code params+userId+role}),越授权客户不可导出。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>合规口径</b>:客户档案导出为合规能力(个保法),导出 {@code phone/realName/idCard} 等敏感字段须
|
|
|
+ * 强制脱敏/最小化(复用 {@code DataMaskUtil},对齐 SC-S-11 {@code supplier.data.exportCompliance} 契约口径)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:宿主 {@code FamilyExportController} 未实现,全部用例在反射探测处失败(红),
|
|
|
+ * 断言原文为「契约宿主 FamilyExportController 未实现」;GREEN 阶段按上述契约落地后转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯 JUnit + 反射结构契约,无 Spring / 无 DB / 无外部服务(同批
|
|
|
+ * {@code TeamProfitHookContractTest} / {@code TeamStarMultiplierContractTest} 风格)。</p>
|
|
|
+ */
|
|
|
+class TeacherExportContractTest {
|
|
|
+
|
|
|
+ /** 设计宿主类(SC-T-29 客户档案导出控制器,派发单设计锚点;当前实现未落地)。 */
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.controller.FamilyExportController";
|
|
|
+
|
|
|
+ /** 导出入口方法名(契约定稿,GREEN 实现方按此落地)。 */
|
|
|
+ private static final String ENTRY_METHOD = "exportClientArchive";
|
|
|
+
|
|
|
+ /** SC-T-13 同源授权守卫(全项目唯一授权判定点,现有类):绑定关系就是导出授权源。 */
|
|
|
+ private static final String AUTHORITY_GUARD = "com.etotem.cfc.service.GuideFamilyAccessGuard";
|
|
|
+
|
|
|
+ // ==================== 正常流(TC-T-29):宿主控制器 + 导出入口 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:FamilyExportController 客户档案导出控制器应存在(设计宿主锚点)")
|
|
|
+ void controller_classExists_teacherClientExport() {
|
|
|
+ Class<?> clazz = hostClass();
|
|
|
+ assertNotNull(clazz,
|
|
|
+ "[teacher.client.export] FamilyExportController 类不存在:SC-T-29 客户档案数据导出(合规)"
|
|
|
+ + "要求规划师侧客户档案导出入口(契约缺口 G-3,AdminOrgMemberController 无 export)。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:导出入口方法 exportClientArchive 应存在(导出范围入参 ≥1)")
|
|
|
+ void controller_hasExportEntryMethod() {
|
|
|
+ Class<?> clazz = hostClass();
|
|
|
+ Method entry = findMethod(clazz, ENTRY_METHOD, 1);
|
|
|
+ assertNotNull(entry,
|
|
|
+ "[teacher.client.export] FamilyExportController 应提供 " + ENTRY_METHOD + "(...) 导出入口"
|
|
|
+ + "(规划师导出其名下客户档案,导出范围显式入参,TC-T-29 正常流)。实现未落地:宿主类不存在。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界/异常流(TC-T-29-E):范围入参 + 统一 Result ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:导出范围显式入参(Map 或数值 ID 载体)——越授权可拒 / 空范围可拒绝")
|
|
|
+ void exportEntry_hasExplicitScopeParam() {
|
|
|
+ Class<?> clazz = hostClass();
|
|
|
+ Method entry = findMethod(clazz, ENTRY_METHOD, 1);
|
|
|
+ assertNotNull(entry, "[teacher.client.export] 导出入口缺失(宿主未实现),无法校验范围入参契约。");
|
|
|
+ boolean scopeAware = false;
|
|
|
+ for (Class<?> p : entry.getParameterTypes()) {
|
|
|
+ if (java.util.Map.class.isAssignableFrom(p) || Number.class.isAssignableFrom(p)) {
|
|
|
+ scopeAware = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(scopeAware,
|
|
|
+ "[teacher.client.export] 导出入口应携带显式导出范围(Map 参数或 familyId/memberId 数值 ID),"
|
|
|
+ + "支撑「仅导出绑定客户 / 越授权可拒 / 空范围返回错误」(TC-T-29-E 边界流;"
|
|
|
+ + "对齐 API_REFERENCE §4.61.5 缺口 G-05 教训:无 memberId 入参则无按范围导出)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-1:导出入口统一返回 Result 包装(失败 Result.error,不裸抛异常)")
|
|
|
+ void exportEntry_returnsUnifiedResult() {
|
|
|
+ Class<?> clazz = hostClass();
|
|
|
+ Method entry = findMethod(clazz, ENTRY_METHOD, 1);
|
|
|
+ assertNotNull(entry, "[teacher.client.export] 导出入口缺失(宿主未实现),无法校验返回契约。");
|
|
|
+ String ret = entry.getReturnType().getName();
|
|
|
+ assertTrue(ret.contains("Result"),
|
|
|
+ "[teacher.client.export] 导出入口应统一返回 Result 包装(成功 Result.success(payload) / "
|
|
|
+ + "失败 Result.error),不得裸抛异常或返回裸 Map(TC-T-29-E 异常流;仓库统一响应约定)。"
|
|
|
+ + "当前返回类型:" + ret);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(TC-T-29-P):teacher 角色守卫 + 绑定授权源 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-1:注入 GuideFamilyAccessGuard 授权守卫或方法携带角色上下文(绑定客户才可导出)")
|
|
|
+ void exportEntry_teacherRoleGuardOrAuthorityGuard() {
|
|
|
+ Class<?> clazz = hostClass();
|
|
|
+ Method entry = findMethod(clazz, ENTRY_METHOD, 1);
|
|
|
+ assertNotNull(entry, "[teacher.client.export] 导出入口缺失(宿主未实现),无法校验角色守卫契约。");
|
|
|
+ boolean guarded = false;
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (AUTHORITY_GUARD.equals(f.getType().getName())) {
|
|
|
+ guarded = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!guarded && entry.getParameterCount() >= 3) {
|
|
|
+ // Controller 惯例:params + userId + role(@RequestAttribute),角色上下文在签名中
|
|
|
+ guarded = true;
|
|
|
+ }
|
|
|
+ assertTrue(guarded,
|
|
|
+ "[teacher.client.export] 导出入口须有 teacher 角色守卫:注入 " + AUTHORITY_GUARD
|
|
|
+ + "(全项目唯一授权判定点,guide_families.status='binding' isBound 授权源,SC-T-13 同源)"
|
|
|
+ + "或导出方法携带角色上下文参数(params+userId+role)——非绑定客户不得导出、非 teacher 角色拒绝"
|
|
|
+ + "(TC-T-29-P 权限流)。实现未落地:宿主类不存在。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 合规流:敏感字段脱敏契约(对齐 SC-S-11 口径) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("合规流-1:客户档案导出须强制脱敏(phone/realName/idCard,对齐 SC-S-11)")
|
|
|
+ void export_mandatoryMaskingContract() {
|
|
|
+ Class<?> clazz = hostClass();
|
|
|
+ assertNotNull(clazz,
|
|
|
+ "[teacher.client.export] FamilyExportController 未实现(合规流前置缺失):客户档案导出为合规能力,"
|
|
|
+ + "导出 phone/realName/idCard 等敏感字段须强制脱敏/最小化(复用 DataMaskUtil,"
|
|
|
+ + "对齐 SC-S-11 supplier.data.exportCompliance 契约口径)。宿主类存在后本契约随正常流用例转绿。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
|
|
|
+
|
|
|
+ /** 解析宿主类;不存在即断言失败(RED 信号:实现未落地),并清晰贴出缺口。 */
|
|
|
+ private Class<?> hostClass() {
|
|
|
+ try {
|
|
|
+ return Class.forName(HOST_CLASS);
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ fail("RED[teacher.client.export] 契约宿主 " + HOST_CLASS + " 未实现(实现未落地):"
|
|
|
+ + "SC-T-29 客户档案数据导出(合规)规划师侧导出入口缺失(契约缺口 G-3:"
|
|
|
+ + "AdminOrgMemberController 仅 list/add/remove/change-type,无 export;"
|
|
|
+ + "UserController /data-export 仅为登录用户本人的个保法可携带权导出)。"
|
|
|
+ + "缺失详情:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按方法名 + 最小参数个数匹配,返回首个命中;无则返回 null。 */
|
|
|
+ private Method findMethod(Class<?> clazz, String name, int minParams) {
|
|
|
+ if (clazz == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals(name) && m.getParameterCount() >= minParams) {
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+}
|