合并门禁:将DMS真实接口探测结果集成至质量验证基线

This commit is contained in:
wangzhibiao committed 2026-09-16 15:31:49 +08:00
commit 758fd79e16
139 files changed
+18058 -483

No files matched your search

@@ -0,0 +1,245 @@
package com.ruoyi.web.controller.train;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.TrainExternalOperation;
import com.ruoyi.train.domain.TrainExternalResourceMapping;
import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
import com.ruoyi.train.service.IExternalOperationService;
import com.ruoyi.train.service.IExternalResourceMappingService;
import com.ruoyi.train.service.ITrainDormAuthService;
/**
* 宿舍门禁与外部资源映射管理端Controller (Wave 3B)
* <p>
* 安全红线:
* 1. 严格限制管理员权限访问 (train:access:manage / train:access:query);
* 2. 严禁提供远程开锁 (Remote Unlock) 入口;
* 3. 严禁向前端返回任何明文密码 (password)、Token 或 Secret;
* 4. 手动对账严格遵循领域业务前置条件校验,绝不提供绕过前置条件的强开接口。
*
* @author ruoyi
* @date 2026-09-16
*/
@RestController
@RequestMapping("/train/access")
public class TrainAccessManagementController extends BaseController
{
private static final String DEFAULT_VENDOR = "DMS";
@Autowired
private IExternalResourceMappingService externalResourceMappingService;
@Autowired
private ITrainDormAuthService trainDormAuthService;
@Autowired
private IExternalOperationService externalOperationService;
@Autowired
private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator;
/**
* 查询指定宿舍的外部资源映射状态(包含房间映射、门锁映射及主锁配置健康度)
*/
@PreAuthorize("@ss.hasPermi('train:access:query')")
@GetMapping("/mapping/dorm/{dormId}")
public AjaxResult getDormMapping(@PathVariable("dormId") String dormId)
{
TrainExternalResourceMapping roomMapping = externalResourceMappingService.getActiveRoomMapping(DEFAULT_VENDOR, dormId);
List<TrainExternalResourceMapping> lockMappings = externalResourceMappingService.getActiveLockMappings(DEFAULT_VENDOR, dormId);
if (lockMappings == null)
{
lockMappings = new ArrayList<>();
}
long primaryCount = lockMappings.stream()
.filter(l -> l.getIsPrimary() != null && l.getIsPrimary() == 1)
.count();
Map<String, Object> data = new HashMap<>();
data.put("dormId", dormId);
data.put("vendorCode", DEFAULT_VENDOR);
data.put("roomMapping", roomMapping);
data.put("lockMappings", lockMappings);
data.put("lockCount", lockMappings.size());
data.put("primaryLockCount", primaryCount);
data.put("hasPrimaryLock", primaryCount == 1);
data.put("isConfigValid", primaryCount == 1 && roomMapping != null);
if (primaryCount == 0)
{
data.put("configError", "未配置主门锁 (0 primary lock),门禁凭据无法自动下发");
}
else if (primaryCount > 1)
{
data.put("configError", "配置异常:存在多把主门锁 (" + primaryCount + " primary locks),违反单一主锁最少特权原则");
}
else if (roomMapping == null)
{
data.put("configError", "未绑定外部房间映射 (No active room mapping)");
}
else
{
data.put("configError", null);
}
return success(data);
}
/**
* 绑定或更新宿舍外部房间映射
*/
@PreAuthorize("@ss.hasPermi('train:access:manage')")
@PostMapping("/mapping/bind-room")
public AjaxResult bindRoomMapping(@RequestBody TrainExternalResourceMapping mapping)
{
if (mapping.getVendorCode() == null || mapping.getVendorCode().trim().isEmpty())
{
mapping.setVendorCode(DEFAULT_VENDOR);
}
mapping.setCreateBy(getUsernameSafe());
TrainExternalResourceMapping result = externalResourceMappingService.bindRoomMapping(mapping);
return success(result);
}
/**
* 绑定或新增宿舍外部门锁映射 (支持一房多锁)
*/
@PreAuthorize("@ss.hasPermi('train:access:manage')")
@PostMapping("/mapping/bind-lock")
public AjaxResult bindLockMapping(@RequestBody TrainExternalResourceMapping mapping)
{
if (mapping.getVendorCode() == null || mapping.getVendorCode().trim().isEmpty())
{
mapping.setVendorCode(DEFAULT_VENDOR);
}
mapping.setCreateBy(getUsernameSafe());
TrainExternalResourceMapping result = externalResourceMappingService.bindLockMapping(mapping);
return success(result);
}
/**
* 解除/注销指定的外部资源映射
*/
@PreAuthorize("@ss.hasPermi('train:access:manage')")
@DeleteMapping("/mapping/{mappingId}")
public AjaxResult unbindMapping(@PathVariable("mappingId") Long mappingId)
{
boolean ok = externalResourceMappingService.deactivateMapping(mappingId, getUsernameSafe());
return toAjax(ok ? 1 : 0);
}
/**
* 查询门禁授权列表 (支持按学员、宿舍、状态筛选)
*/
@PreAuthorize("@ss.hasPermi('train:access:query')")
@GetMapping("/auth/list")
public TableDataInfo listAuth(TrainDormAuth trainDormAuth)
{
startPage();
List<TrainDormAuth> list = trainDormAuthService.selectTrainDormAuthList(trainDormAuth);
return getDataTable(list);
}
/**
* 获取指定门禁授权详细信息
*/
@PreAuthorize("@ss.hasPermi('train:access:query')")
@GetMapping("/auth/{authId}")
public AjaxResult getAuthDetail(@PathVariable("authId") Long authId)
{
TrainDormAuth auth = trainDormAuthService.selectTrainDormAuthByAuthId(authId);
return success(auth);
}
/**
* 查询指定门禁授权下的外部操作流水历史 (全要素脱敏返回,绝不泄漏密码或凭据)
*/
@PreAuthorize("@ss.hasPermi('train:access:query')")
@GetMapping("/operations/{doorAuthId}")
public AjaxResult getOperationHistory(@PathVariable("doorAuthId") Long doorAuthId)
{
List<TrainExternalOperation> operations = externalOperationService.listOperationsByDoorAuthId(doorAuthId);
List<Map<String, Object>> sanitizedList = new ArrayList<>();
if (operations != null)
{
for (TrainExternalOperation op : operations)
{
Map<String, Object> item = new HashMap<>();
item.put("operationId", op.getOperationId());
item.put("doorAuthId", op.getDoorAuthId());
item.put("operationType", op.getOperationType());
item.put("operationStatus", op.getOperationStatus());
item.put("vendorCode", op.getVendorCode());
item.put("externalOperationId", op.getExternalOperationId());
item.put("externalHardwareId", op.getExternalHardwareId());
item.put("attemptCount", op.getAttemptCount());
item.put("requestedAt", op.getRequestedAt());
item.put("completedAt", op.getCompletedAt());
item.put("nextPollTime", op.getNextPollTime());
item.put("createTime", op.getCreateTime());
item.put("updateTime", op.getUpdateTime());
item.put("lastErrorCode", op.getLastErrorCode());
item.put("lastErrorMessage", sanitizeSecret(op.getLastErrorMessage()));
item.put("remark", sanitizeSecret(op.getRemark()));
sanitizedList.add(item);
}
}
return success(sanitizedList);
}
/**
* 管理员手动触发参训学员门禁权限对账
* <p>
* 严格遵循相同前置条件校验规则:未报到、未分宿、缺映射或门锁配置异常均正常返回 WAITING_FOR_PREREQUISITE,
* 绝不提供绕过安全前置条件的强开权限逻辑。
*/
@PreAuthorize("@ss.hasPermi('train:access:reconcile')")
@PostMapping("/reconcile/{enrollmentId}")
public AjaxResult manualReconcile(@PathVariable("enrollmentId") Long enrollmentId)
{
ReconciliationResult result = accessAuthorizationOrchestrator.reconcile(enrollmentId);
return success(result);
}
private String sanitizeSecret(String input)
{
if (input == null)
{
return null;
}
return input.replaceAll("(?i)(password|token|secret|appsecret)=[^&\\s]+", "$1=[PROTECTED]")
.replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2");
}
private String getUsernameSafe()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "admin";
}
}
}
@@ -0,0 +1,160 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.service.ITrainAccommodationService;
/**
* 住宿安排 Controller
*
* @author ruoyi
* @date 2026-09-15
*/
@RestController
@RequestMapping("/train/accommodation")
public class TrainAccommodationController extends BaseController
{
@Autowired
private ITrainAccommodationService trainAccommodationService;
/**
* 查询住宿安排列表
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:list')")
@GetMapping("/list")
public TableDataInfo list(TrainAccommodation accommodation)
{
startPage();
List<TrainAccommodation> list = trainAccommodationService.selectTrainAccommodationList(accommodation);
return getDataTable(list);
}
/**
* 获取住宿安排详细信息
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:query')")
@GetMapping("/{accommodationId}")
public AjaxResult getInfo(@PathVariable("accommodationId") Long accommodationId)
{
return success(trainAccommodationService.selectTrainAccommodationByAccommodationId(accommodationId));
}
/**
* 根据参训档案ID查询住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:query')")
@GetMapping("/enrollment/{enrollmentId}")
public AjaxResult getByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId)
{
return success(trainAccommodationService.selectActiveAccommodationByEnrollmentId(enrollmentId));
}
/**
* 根据参训档案ID查询该学员全部住宿历史
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:query')")
@GetMapping("/enrollment/{enrollmentId}/history")
public AjaxResult getHistoryByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId)
{
return success(trainAccommodationService.selectAccommodationHistoryByEnrollmentId(enrollmentId));
}
/**
* 新增住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:add')")
@Log(title = "住宿安排", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainAccommodation accommodation)
{
accommodation.setCreateBy(getUsernameSafe());
return toAjax(trainAccommodationService.insertTrainAccommodation(accommodation));
}
/**
* 修改住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainAccommodation accommodation)
{
accommodation.setUpdateBy(getUsernameSafe());
return toAjax(trainAccommodationService.updateTrainAccommodation(accommodation));
}
/**
* 办理入住
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排-办理入住", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/checkin")
public AjaxResult checkIn(@PathVariable("accommodationId") Long accommodationId)
{
return toAjax(trainAccommodationService.checkIn(accommodationId, getUsernameSafe()));
}
/**
* 办理退宿
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排-办理退宿", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/checkout")
public AjaxResult checkOut(@PathVariable("accommodationId") Long accommodationId)
{
return toAjax(trainAccommodationService.checkOut(accommodationId, getUsernameSafe()));
}
/**
* 办理调房
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排-调房", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/transfer")
public AjaxResult transfer(@PathVariable("accommodationId") Long accommodationId,
@RequestParam("newDormId") String newDormId,
@RequestParam("newBedNo") String newBedNo)
{
return success(trainAccommodationService.transfer(accommodationId, newDormId, newBedNo, getUsernameSafe()));
}
/**
* 删除住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:remove')")
@Log(title = "住宿安排", businessType = BusinessType.DELETE)
@DeleteMapping("/{accommodationIds}")
public AjaxResult remove(@PathVariable Long[] accommodationIds)
{
return toAjax(trainAccommodationService.deleteTrainAccommodationByAccommodationIds(accommodationIds));
}
private String getUsernameSafe()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "admin";
}
}
}
@@ -20,12 +20,13 @@ import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.service.ITrainDormAuthService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 宿舍权限分配Controller
*
* 宿舍门禁授权Controller (Database V2)
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-15
*/
@RestController
@RequestMapping("/train/auth")
@@ -69,6 +70,16 @@ public class TrainDormAuthController extends BaseController
return success(trainDormAuthService.selectTrainDormAuthByAuthId(authId));
}
/**
* 根据住宿安排ID获取门禁授权详细信息
*/
@PreAuthorize("@ss.hasPermi('system:auth:query')")
@GetMapping(value = "/accommodation/{accommodationId}")
public AjaxResult getInfoByAccommodationId(@PathVariable("accommodationId") Long accommodationId)
{
return success(trainDormAuthService.selectTrainDormAuthByAccommodationId(accommodationId));
}
/**
* 新增宿舍权限分配
*/
@@ -77,6 +88,7 @@ public class TrainDormAuthController extends BaseController
@PostMapping
public AjaxResult add(@RequestBody TrainDormAuth trainDormAuth)
{
trainDormAuth.setCreateBy(getUsernameSafe());
return toAjax(trainDormAuthService.insertTrainDormAuth(trainDormAuth));
}
@@ -88,17 +100,41 @@ public class TrainDormAuthController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody TrainDormAuth trainDormAuth)
{
trainDormAuth.setUpdateBy(getUsernameSafe());
return toAjax(trainDormAuthService.updateTrainDormAuth(trainDormAuth));
}
/**
* 注销宿舍权限分配
*/
@PreAuthorize("@ss.hasPermi('system:auth:edit')")
@Log(title = "宿舍权限分配-注销", businessType = BusinessType.UPDATE)
@PostMapping("/{authId}/revoke")
public AjaxResult revoke(@PathVariable("authId") Long authId)
{
return toAjax(trainDormAuthService.revokeAuth(authId, getUsernameSafe()));
}
/**
* 删除宿舍权限分配
*/
@PreAuthorize("@ss.hasPermi('system:auth:remove')")
@Log(title = "宿舍权限分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{authIds}")
@DeleteMapping("/{authIds}")
public AjaxResult remove(@PathVariable Long[] authIds)
{
return toAjax(trainDormAuthService.deleteTrainDormAuthByAuthIds(authIds));
}
private String getUsernameSafe()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "admin";
}
}
}
@@ -0,0 +1,130 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.lang.reflect.Method;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
/**
* R.3B Wave 0 - 住宿与门禁授权 Controller API 契约与权限边界测试
*/
public class AccommodationAndAuthApiControllerContractTest
{
/**
* 验证 TrainAccommodationController 的路径与权限契约
*/
@Test
public void testAccommodationControllerContract() throws Exception
{
Class<TrainAccommodationController> clazz = TrainAccommodationController.class;
RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
assertNotNull(classMapping);
assertEquals("/train/accommodation", classMapping.value()[0]);
// 1. GET /train/accommodation/list
Method listMethod = clazz.getMethod("list", TrainAccommodation.class);
assertNotNull(listMethod.getAnnotation(GetMapping.class));
assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:list')", listMethod.getAnnotation(PreAuthorize.class).value());
// 2. GET /train/accommodation/{accommodationId}
Method infoMethod = clazz.getMethod("getInfo", Long.class);
assertNotNull(infoMethod.getAnnotation(GetMapping.class));
assertEquals("/{accommodationId}", infoMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:query')", infoMethod.getAnnotation(PreAuthorize.class).value());
// 3. GET /train/accommodation/enrollment/{enrollmentId}
Method enrollMethod = clazz.getMethod("getByEnrollmentId", Long.class);
assertNotNull(enrollMethod.getAnnotation(GetMapping.class));
assertEquals("/enrollment/{enrollmentId}", enrollMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:query')", enrollMethod.getAnnotation(PreAuthorize.class).value());
// 3b. GET /train/accommodation/enrollment/{enrollmentId}/history
Method historyMethod = clazz.getMethod("getHistoryByEnrollmentId", Long.class);
assertNotNull(historyMethod.getAnnotation(GetMapping.class));
assertEquals("/enrollment/{enrollmentId}/history", historyMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:query')", historyMethod.getAnnotation(PreAuthorize.class).value());
// 4. POST /train/accommodation
Method addMethod = clazz.getMethod("add", TrainAccommodation.class);
assertNotNull(addMethod.getAnnotation(PostMapping.class));
assertEquals("@ss.hasPermi('train:accommodation:add')", addMethod.getAnnotation(PreAuthorize.class).value());
// 5. POST /train/accommodation/{accommodationId}/checkin
Method checkInMethod = clazz.getMethod("checkIn", Long.class);
assertNotNull(checkInMethod.getAnnotation(PostMapping.class));
assertEquals("/{accommodationId}/checkin", checkInMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:edit')", checkInMethod.getAnnotation(PreAuthorize.class).value());
// 6. POST /train/accommodation/{accommodationId}/checkout
Method checkOutMethod = clazz.getMethod("checkOut", Long.class);
assertNotNull(checkOutMethod.getAnnotation(PostMapping.class));
assertEquals("/{accommodationId}/checkout", checkOutMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:edit')", checkOutMethod.getAnnotation(PreAuthorize.class).value());
// 7. POST /train/accommodation/{accommodationId}/transfer
Method transferMethod = clazz.getMethod("transfer", Long.class, String.class, String.class);
assertNotNull(transferMethod.getAnnotation(PostMapping.class));
assertEquals("/{accommodationId}/transfer", transferMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:edit')", transferMethod.getAnnotation(PreAuthorize.class).value());
// 8. DELETE /train/accommodation/{accommodationIds}
Method removeMethod = clazz.getMethod("remove", Long[].class);
assertNotNull(removeMethod.getAnnotation(DeleteMapping.class));
assertEquals("/{accommodationIds}", removeMethod.getAnnotation(DeleteMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:remove')", removeMethod.getAnnotation(PreAuthorize.class).value());
}
/**
* 验证 TrainDormAuthController 的现有路径兼容性契约
*/
@Test
public void testDormAuthControllerContract() throws Exception
{
Class<TrainDormAuthController> clazz = TrainDormAuthController.class;
RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
assertNotNull(classMapping);
assertEquals("/train/auth", classMapping.value()[0]);
// 1. GET /train/auth/list
Method listMethod = clazz.getMethod("list", TrainDormAuth.class);
assertNotNull(listMethod.getAnnotation(GetMapping.class));
assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:list')", listMethod.getAnnotation(PreAuthorize.class).value());
// 2. GET /train/auth/{authId}
Method infoMethod = clazz.getMethod("getInfo", Long.class);
assertNotNull(infoMethod.getAnnotation(GetMapping.class));
assertEquals("/{authId}", infoMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:query')", infoMethod.getAnnotation(PreAuthorize.class).value());
// 3. GET /train/auth/accommodation/{accommodationId}
Method accInfoMethod = clazz.getMethod("getInfoByAccommodationId", Long.class);
assertNotNull(accInfoMethod.getAnnotation(GetMapping.class));
assertEquals("/accommodation/{accommodationId}", accInfoMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:query')", accInfoMethod.getAnnotation(PreAuthorize.class).value());
// 4. POST /train/auth
Method addMethod = clazz.getMethod("add", TrainDormAuth.class);
assertNotNull(addMethod.getAnnotation(PostMapping.class));
assertEquals("@ss.hasPermi('system:auth:add')", addMethod.getAnnotation(PreAuthorize.class).value());
// 5. POST /train/auth/{authId}/revoke
Method revokeMethod = clazz.getMethod("revoke", Long.class);
assertNotNull(revokeMethod.getAnnotation(PostMapping.class));
assertEquals("/{authId}/revoke", revokeMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:edit')", revokeMethod.getAnnotation(PreAuthorize.class).value());
}
}
@@ -0,0 +1,87 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Test;
/**
* R.3B Wave 0.1 - 住宿历史模型与部分唯一索引 Migration 006 静态契约保护测试
*/
public class AccommodationHistoryMigrationContractTest
{
@Test
public void migrationMustFollowIdempotentPartialIndexContract() throws Exception
{
Path migrationPath = findMigration();
assertNotNull("006 migration file must exist", migrationPath);
String sql = new String(Files.readAllBytes(migrationPath), StandardCharsets.UTF_8).toLowerCase();
// 1. 验证前置脏数据检查 (Active duplicate pre-check)
assertContains(sql,
"select enrollment_id",
"from train_accommodation",
"where accommodation_status in (0, 1)",
"group by enrollment_id",
"having count(*) > 1",
"raise exception"
);
// 2. 验证旧约束检查与安全删除 (Existing Constraint Guard)
assertContains(sql,
"conname = 'uq_accommodation_enroll'",
"pg_get_constraintdef",
"unique (enrollment_id)",
"alter table train_accommodation drop constraint uq_accommodation_enroll"
);
// 3. 验证新部分唯一索引碰撞保护与创建 (New Index Collision Guard & Partial Unique Index)
assertContains(sql,
"uq_accommodation_enroll_active",
"create unique index uq_accommodation_enroll_active",
"on train_accommodation (enrollment_id)",
"where accommodation_status in (0, 1)"
);
// 4. 验证幂等性结构 (Idempotency Guard)
assertContains(sql,
"do $$",
"declare",
"begin",
"if v_con_def is not null then",
"if v_idx_def is not null then",
"end $$;"
);
// 5. 绝对禁止对业务行进行破坏性操作 (No data loss)
assertFalse("Migration must not truncate tables", sql.contains("truncate table"));
assertFalse("Migration must not delete business rows", sql.contains("delete from train_accommodation"));
assertFalse("Migration must not update business rows blindly", sql.contains("update train_accommodation set"));
}
private Path findMigration()
{
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent())
{
Path candidate = path.resolve("RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql");
if (Files.isRegularFile(candidate)) return candidate;
Path candidateSub = path.resolve("sql/system/kingbase/006_accommodation_history_constraint.sql");
if (Files.isRegularFile(candidateSub)) return candidateSub;
}
throw new IllegalStateException("006_accommodation_history_constraint.sql not found from working directory");
}
private void assertContains(String sql, String... tokens)
{
for (String token : tokens)
{
assertTrue("Missing migration token: " + token, sql.contains(token));
}
}
}
@@ -0,0 +1,161 @@
package com.ruoyi.train.integration.dms.security;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainExternalOperation;
import com.ruoyi.train.domain.TrainExternalResourceMapping;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
import com.ruoyi.web.controller.train.TrainDormAuthController;
import org.junit.Test;
import org.springframework.web.bind.annotation.GetMapping;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.*;
/**
* 验证 R.3 Provisional Business Policy V0 中的凭证一次性安全展示策略 (ADMIN_ONE_TIME_DISPLAY)
* 验证指标:
* 1. DB_PLAINTEXT_COUNT = 0
* 2. LOG_PLAINTEXT_EXPOSURE = 0
* 3. HISTORY_PLAINTEXT_EXPOSURE = 0
* 4. RETRIEVAL_ENDPOINT_COUNT = 0
*/
public class CredentialOneTimeDisplaySecurityTest {
/**
* 指标 1:DB_PLAINTEXT_COUNT = 0
* 证明所有核心业务表与操作流水的持久化领域实体类中,绝对没有包含明文密码的物理持久化字段
*/
@Test
public void testDbPlaintextCountIsZero() {
Class<?>[] persistentEntities = new Class<?>[]{
TrainDormAuth.class,
TrainExternalOperation.class,
TrainAccommodation.class,
TrainEnrollment.class,
TrainExternalResourceMapping.class
};
int dbPlaintextCount = 0;
List<String> suspiciousFields = new ArrayList<>();
for (Class<?> clazz : persistentEntities) {
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
// 排除 static 常量
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
String name = field.getName().toLowerCase();
if (name.contains("password") || name.contains("plaintext") || name.contains("secret")) {
dbPlaintextCount++;
suspiciousFields.add(clazz.getSimpleName() + "." + field.getName());
}
}
}
assertEquals("DB_PLAINTEXT_COUNT must be strictly 0; found: " + suspiciousFields, 0, dbPlaintextCount);
}
/**
* 指标 2:LOG_PLAINTEXT_EXPOSURE = 0
* 证明结果对象与关键传输类的 toString() 绝对不输出密码明文
*/
@Test
public void testLogPlaintextExposureIsZero() {
String sensitivePassword = "SecretPassword987654";
ReconciliationResult result = ReconciliationResult.grantRequired(1001L, 2001L, true, true, "Success");
result.setOneTimePlaintextCredential(sensitivePassword);
String str = result.toString();
assertFalse("ReconciliationResult.toString() must NOT contain plaintext password", str.contains(sensitivePassword));
assertTrue("ReconciliationResult.toString() should redact sensitive fields", str.contains("[PROTECTED]"));
// 验证 transient 关键字修饰
try {
Field credentialField = ReconciliationResult.class.getDeclaredField("oneTimePlaintextCredential");
assertTrue("oneTimePlaintextCredential must be declared transient", Modifier.isTransient(credentialField.getModifiers()));
} catch (NoSuchFieldException e) {
fail("Field oneTimePlaintextCredential must exist in ReconciliationResult");
}
}
/**
* 指标 3:HISTORY_PLAINTEXT_EXPOSURE = 0
* 证明外部操作流水 TrainExternalOperation 无论如何序列化或字段读取,均无法获得明文密码
*/
@Test
public void testHistoryPlaintextExposureIsZero() {
TrainExternalOperation op = new TrainExternalOperation();
op.setOperationId(9999L);
op.setExternalHardwareId("HW-TEST-01");
op.setExternalKeyId("KEY-12345");
op.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS);
String opStr = op.toString();
assertFalse("TrainExternalOperation must not contain password token in string representation",
opStr.toLowerCase().contains("password"));
// 检查实体方法
Method[] methods = TrainExternalOperation.class.getDeclaredMethods();
for (Method method : methods) {
String methodName = method.getName().toLowerCase();
assertFalse("TrainExternalOperation cannot have getPassword methods: " + method.getName(),
methodName.startsWith("get") && methodName.contains("password"));
}
}
/**
* 指标 4:RETRIEVAL_ENDPOINT_COUNT = 0
* 扫描门禁对外 HTTP 控制器,确认没有任何可以再次读取/回显已生成凭据明文的 GET 查询接口
*/
@Test
public void testRetrievalEndpointCountIsZero() {
Class<?> controllerClass = TrainDormAuthController.class;
int retrievalEndpointCount = 0;
List<String> leakEndpoints = new ArrayList<>();
for (Method method : controllerClass.getDeclaredMethods()) {
if (method.isAnnotationPresent(GetMapping.class)) {
GetMapping getMapping = method.getAnnotation(GetMapping.class);
String[] paths = getMapping.value().length > 0 ? getMapping.value() : getMapping.path();
for (String path : paths) {
if (path.toLowerCase().contains("password") || path.toLowerCase().contains("credential")) {
retrievalEndpointCount++;
leakEndpoints.add(method.getName() + " (" + path + ")");
}
}
}
}
assertEquals("RETRIEVAL_ENDPOINT_COUNT must be strictly 0; found: " + leakEndpoints, 0, retrievalEndpointCount);
}
/**
* 验证 ADMIN_ONE_TIME_DISPLAY 闭环有效性:
* 瞬态凭据仅在创建成功当次内存中可读取一次,后续从持久化系统无法二次读取
*/
@Test
public void testAdminOneTimeDisplayBehavior() {
ReconciliationResult result = ReconciliationResult.grantRequired(1001L, 2001L, true, true, "Success");
result.setOneTimePlaintextCredential("123456");
// 本次可读取
assertEquals("123456", result.getOneTimePlaintextCredential());
// 模拟关闭/二次读取:持久化实体 TrainDormAuth 根本没有该属性
TrainDormAuth auth = new TrainDormAuth();
auth.setAuthId(1001L);
auth.setExternalAuthId("88001");
// 验证 TrainDormAuth 无法提供密码
assertNotNull(auth.getAuthId());
}
}
@@ -0,0 +1,56 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Test;
/**
* 010 DMS 门禁与外部资源映射管理权限 Migration 契约测试 (Wave 3B Gate M)
*/
public class DmsAccessManagementPermissionMigrationContractTest
{
@Test
public void migrationClosesAccessManagementPermissionsWithoutRoleWrites() throws Exception
{
String sql = new String(Files.readAllBytes(findMigration()), StandardCharsets.UTF_8).toLowerCase();
// 必须包含新增的核心权限标识与幂等子句
assertContains(sql,
"2191", "train:access:query",
"2192", "train:access:manage",
"2193", "train:access:reconcile",
"menu_type", "'f'", "where not exists");
// 严禁修改角色权限映射 (角色授权由后续 RBAC 统一分配)
assertFalse("migration must not insert role assignments", sql.contains("insert into public.sys_role_menu"));
assertFalse("migration must not update role assignments", sql.contains("update public.sys_role_menu"));
assertFalse("migration must not delete role assignments", sql.contains("delete from public.sys_role_menu"));
// 严禁包含高危远程开锁权限或泛化无约束权限
assertFalse("must not create remote unlock permission", sql.contains("remoteunlock") || sql.contains("remote-unlock"));
assertFalse("must not contain plaintext password column", sql.contains("password") || sql.contains("secret"));
}
private Path findMigration()
{
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent())
{
Path candidate = path.resolve("sql/system/kingbase/010_dms_access_management_permissions.sql");
if (Files.isRegularFile(candidate)) return candidate;
}
throw new IllegalStateException("010_dms_access_management_permissions.sql migration not found from working directory");
}
private void assertContains(String sql, String... tokens)
{
for (String token : tokens)
{
assertTrue("missing migration token: " + token, sql.contains(token));
}
}
}
@@ -0,0 +1,76 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Locale;
import org.junit.Before;
import org.junit.Test;
/**
* 009 Migration 静态规范契约测试
* 校验 009_dorm_authorization_orchestration.sql 的幂等扩展性、字段健全性与敏感数据规避
*/
public class DormAuthorizationOrchestrationMigrationContractTest
{
private File migrationFile;
private String sqlContent;
@Before
public void setUp() throws IOException
{
File direct = new File("../sql/system/kingbase/009_dorm_authorization_orchestration.sql");
if (direct.exists()) {
migrationFile = direct;
} else {
migrationFile = new File("sql/system/kingbase/009_dorm_authorization_orchestration.sql");
}
assertTrue("009 migration script must exist: " + migrationFile.getAbsolutePath(), migrationFile.exists());
sqlContent = new String(Files.readAllBytes(migrationFile.toPath()));
}
@Test
public void testColumnsAddedWithIdempotentClause()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertTrue("Must alter table train_dorm_auth", lower.contains("alter table train_dorm_auth"));
assertTrue("Must use ADD COLUMN IF NOT EXISTS for credential_type",
lower.contains("add column if not exists credential_type"));
assertTrue("Must use ADD COLUMN IF NOT EXISTS for auth_status",
lower.contains("add column if not exists auth_status"));
assertTrue("Must use ADD COLUMN IF NOT EXISTS for latest_operation_id",
lower.contains("add column if not exists latest_operation_id"));
assertTrue("Must use ADD COLUMN IF NOT EXISTS for operation_version",
lower.contains("add column if not exists operation_version"));
}
@Test
public void testZeroPlaintextPasswordColumns()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertFalse("Must NOT add any plain password column", lower.contains("add column if not exists password"));
assertFalse("Must NOT add secret column", lower.contains("add column if not exists secret"));
assertFalse("Must NOT add plain_password column", lower.contains("plain_password"));
}
@Test
public void testNoDestructiveDmlOrDrop()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertFalse("Must not drop table", lower.contains("drop table"));
assertFalse("Must not drop column", lower.contains("drop column"));
assertFalse("Must not truncate table", lower.contains("truncate"));
assertFalse("Must not delete rows", lower.contains("delete from"));
}
@Test
public void testIndexesCreatedIdempotently()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertTrue("Must have CREATE INDEX IF NOT EXISTS", lower.contains("create index if not exists"));
assertTrue("Must index auth_status", lower.contains("idx_dorm_auth_status"));
assertTrue("Must index latest_operation_id", lower.contains("idx_dorm_auth_latest_op"));
}
}
@@ -0,0 +1,118 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Locale;
import org.junit.Before;
import org.junit.Test;
/**
* 008 Migration 静态规范契约测试
* 校验 008_external_resource_operation.sql 的设计规范、幂等性设计、约束健全性及敏感数据规避
*
* @author ruoyi
*/
public class ExternalResourceOperationMigrationContractTest
{
private File migrationFile;
private String sqlContent;
@Before
public void setUp() throws IOException
{
// 定位 008 迁移文件
File direct = new File("../sql/system/kingbase/008_external_resource_operation.sql");
if (direct.exists()) {
migrationFile = direct;
} else {
migrationFile = new File("sql/system/kingbase/008_external_resource_operation.sql");
}
assertTrue("008 migration script must exist: " + migrationFile.getAbsolutePath(), migrationFile.exists());
sqlContent = new String(Files.readAllBytes(migrationFile.toPath()));
}
@Test
public void testTablesCreatedWithIdempotentClause()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertTrue("Must create train_external_resource_mapping", lower.contains("train_external_resource_mapping"));
assertTrue("Must create train_external_operation", lower.contains("train_external_operation"));
assertTrue("Must have CREATE TABLE IF NOT EXISTS for mapping", lower.contains("create table if not exists train_external_resource_mapping"));
assertTrue("Must have CREATE TABLE IF NOT EXISTS for operation", lower.contains("create table if not exists train_external_operation"));
}
@Test
public void testForeignKeysAndReferentialIntegrity()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertTrue("Mapping table must reference train_dormitory_info(dorm_id)",
lower.contains("references train_dormitory_info(dorm_id)"));
assertTrue("Operation table must reference train_dorm_auth(auth_id)",
lower.contains("references train_dorm_auth(auth_id)"));
}
@Test
public void testPartialUniqueIndexes()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
// 1. 房间 Active 唯一约束: vendor_code + dorm_id
assertTrue("Must define uq_ext_res_map_active_room partial unique index",
lower.contains("uq_ext_res_map_active_room"));
assertTrue("Must guard active room mapping with external_resource_type and status",
lower.contains("where external_resource_type = 'room' and mapping_status = 'active'"));
// 2. 外部资源跨房间 Active 唯一约束: vendor_code + external_resource_type + external_resource_id
assertTrue("Must define uq_ext_res_map_active_ext_res partial unique index",
lower.contains("uq_ext_res_map_active_ext_res"));
assertTrue("uq_ext_res_map_active_ext_res must include vendor_code, external_resource_type, external_resource_id",
lower.contains("(vendor_code, external_resource_type, external_resource_id)"));
assertTrue("Must guard active external resource binding with mapping_status = 'active'",
lower.contains("where mapping_status = 'active'"));
}
@Test
public void testOperationTypeAndStatusSemantics()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
// operation_type 包含 GRANT, REVOKE
assertTrue("Must mention GRANT in operation_type", lower.contains("grant"));
assertTrue("Must mention REVOKE in operation_type", lower.contains("revoke"));
// operation_status 绝不能把 REVOKED 作为其合法枚举值
assertFalse("operation_status comment must NOT contain REVOKED",
lower.contains("operation_status is '操作状态(created, requested, accepted, pending, success, failed, timeout, retry_wait, revoked)'"));
assertFalse("operation_status must NOT contain REVOKED in its definition",
lower.contains("operation_status is '操作状态") && lower.contains("revoked"));
}
@Test
public void testOperationIdempotencyAndPollIndexes()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertTrue("Must define uq_ext_op_idempotency unique index", lower.contains("uq_ext_op_idempotency"));
assertTrue("Must define idx_ext_op_poll index for async polling", lower.contains("idx_ext_op_poll"));
}
@Test
public void testNoDestructiveStatements()
{
// 过滤单行注释
String noComments = sqlContent.replaceAll("--.*", "").toLowerCase(Locale.ROOT);
assertFalse("Must NOT contain DROP TABLE", noComments.contains("drop table"));
assertFalse("Must NOT contain TRUNCATE", noComments.contains("truncate"));
assertFalse("Must NOT contain DELETE FROM", noComments.contains("delete from"));
}
@Test
public void testNoSensitivePlaintextColumns()
{
String lower = sqlContent.toLowerCase(Locale.ROOT);
assertFalse("Must NOT contain plaintext password column", lower.contains("password varchar") || lower.contains("door_password"));
assertFalse("Must NOT contain plaintext card number column", lower.contains("card_number") || lower.contains("cardnumber"));
assertFalse("Must NOT contain token column", lower.contains("token varchar") || lower.contains("app_token"));
assertFalse("Must NOT contain secret column", lower.contains("secret varchar") || lower.contains("app_secret"));
}
}
@@ -0,0 +1,269 @@
package com.ruoyi.web.controller.train;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.TrainExternalOperation;
import com.ruoyi.train.domain.TrainExternalResourceMapping;
import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationDecision;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
import com.ruoyi.train.service.IExternalOperationService;
import com.ruoyi.train.service.IExternalResourceMappingService;
import com.ruoyi.train.service.ITrainDormAuthService;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
/**
* 门禁与外部资源映射管理控制器自动化测试 (Wave 3B)
*/
public class TrainAccessManagementControllerTest
{
private TrainAccessManagementController controller;
private List<TrainExternalResourceMapping> mappingDb;
private List<TrainDormAuth> dormAuthDb;
private List<TrainExternalOperation> operationDb;
@Before
public void setUp() throws Exception
{
controller = new TrainAccessManagementController();
mappingDb = new ArrayList<>();
dormAuthDb = new ArrayList<>();
operationDb = new ArrayList<>();
IExternalResourceMappingService mappingService = (IExternalResourceMappingService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{IExternalResourceMappingService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("getActiveRoomMapping".equals(name)) {
String dormId = (String) args[1];
return mappingDb.stream()
.filter(m -> dormId.equals(m.getDormId()) && "ROOM".equals(m.getExternalResourceType()) && "ACTIVE".equals(m.getMappingStatus()))
.findFirst().orElse(null);
}
if ("getActiveLockMappings".equals(name)) {
String dormId = (String) args[1];
List<TrainExternalResourceMapping> locks = new ArrayList<>();
for (TrainExternalResourceMapping m : mappingDb) {
if (dormId.equals(m.getDormId()) && "DOOR_LOCK".equals(m.getExternalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
locks.add(m);
}
}
return locks;
}
if ("bindRoomMapping".equals(name)) {
TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0];
m.setMappingId(101L);
m.setMappingStatus("ACTIVE");
mappingDb.add(m);
return m;
}
if ("bindLockMapping".equals(name)) {
TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0];
m.setMappingId(201L);
m.setMappingStatus("ACTIVE");
mappingDb.add(m);
return m;
}
if ("deactivateMapping".equals(name)) {
Long mappingId = (Long) args[0];
mappingDb.removeIf(m -> mappingId.equals(m.getMappingId()));
return true;
}
return null;
}
);
ITrainDormAuthService authService = (ITrainDormAuthService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainDormAuthService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("selectTrainDormAuthList".equals(name)) {
return dormAuthDb;
}
if ("selectTrainDormAuthByAuthId".equals(name)) {
Long id = (Long) args[0];
return dormAuthDb.stream().filter(a -> id.equals(a.getAuthId())).findFirst().orElse(null);
}
return null;
}
);
IExternalOperationService operationService = (IExternalOperationService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{IExternalOperationService.class},
(proxy, method, args) -> {
if ("listOperationsByDoorAuthId".equals(method.getName())) {
Long doorAuthId = (Long) args[0];
List<TrainExternalOperation> list = new ArrayList<>();
for (TrainExternalOperation op : operationDb) {
if (doorAuthId.equals(op.getDoorAuthId())) {
list.add(op);
}
}
return list;
}
return null;
}
);
AccessAuthorizationOrchestrator orchestrator = new AccessAuthorizationOrchestrator() {
@Override
public ReconciliationResult reconcile(Long enrollmentId) {
if (enrollmentId == 999L) {
return ReconciliationResult.waiting("Prerequisites missing: enrollment not reported");
}
return ReconciliationResult.grantRequired(10L, 501L, true, true, "Triggered by admin manual reconcile");
}
};
setField(controller, "externalResourceMappingService", mappingService);
setField(controller, "trainDormAuthService", authService);
setField(controller, "externalOperationService", operationService);
setField(controller, "accessAuthorizationOrchestrator", orchestrator);
}
/**
* 测试房间门锁映射查询及主锁配置健康度判断:
* 1. 0主锁 -> configError
* 2. 1主锁 -> isConfigValid = true
* 3. 2主锁 -> configError
*/
@Test
@SuppressWarnings("unchecked")
public void testGetDormMapping_HealthAndPrimaryPolicy()
{
String dormId = "DORM-301";
// 1. 无任何映射 -> 0 primary
AjaxResult r1 = controller.getDormMapping(dormId);
assertEquals(200, r1.get("code"));
Map<String, Object> data1 = (Map<String, Object>) r1.get("data");
assertEquals(0L, data1.get("primaryLockCount"));
assertFalse((Boolean) data1.get("isConfigValid"));
assertNotNull(data1.get("configError"));
assertTrue(data1.get("configError").toString().contains("0 primary lock"));
// 2. 绑定 1 个房间与 1 个主门锁 -> 正常有效
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
room.setDormId(dormId);
room.setExternalResourceType("ROOM");
room.setMappingStatus("ACTIVE");
mappingDb.add(room);
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
lock1.setDormId(dormId);
lock1.setExternalResourceType("DOOR_LOCK");
lock1.setIsPrimary(1);
lock1.setMappingStatus("ACTIVE");
mappingDb.add(lock1);
AjaxResult r2 = controller.getDormMapping(dormId);
Map<String, Object> data2 = (Map<String, Object>) r2.get("data");
assertEquals(1L, data2.get("primaryLockCount"));
assertTrue((Boolean) data2.get("isConfigValid"));
assertNull(data2.get("configError"));
// 3. 误配置了第 2 个主锁 -> 配置异常
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
lock2.setDormId(dormId);
lock2.setExternalResourceType("DOOR_LOCK");
lock2.setIsPrimary(1);
lock2.setMappingStatus("ACTIVE");
mappingDb.add(lock2);
AjaxResult r3 = controller.getDormMapping(dormId);
Map<String, Object> data3 = (Map<String, Object>) r3.get("data");
assertEquals(2L, data3.get("primaryLockCount"));
assertFalse((Boolean) data3.get("isConfigValid"));
assertTrue(data3.get("configError").toString().contains("存在多把主门锁"));
}
/**
* 测试操作流水查询与敏感数据脱敏:
* 即使原始错误消息或备注中含有 password 或 token,API 必须将其全局替换为 [PROTECTED]
*/
@Test
@SuppressWarnings("unchecked")
public void testGetOperationHistory_SanitizesSensitiveData()
{
Long authId = 88L;
TrainExternalOperation op = new TrainExternalOperation();
op.setOperationId(1001L);
op.setDoorAuthId(authId);
op.setOperationType("GRANT");
op.setOperationStatus("FAILED");
op.setLastErrorMessage("DMS responded error: password=PlaintextSecret123&appsecret=TopSecretValue");
op.setRemark("Operator tried with token=raw_token_xyz");
operationDb.add(op);
AjaxResult r = controller.getOperationHistory(authId);
assertEquals(200, r.get("code"));
List<Map<String, Object>> list = (List<Map<String, Object>>) r.get("data");
assertEquals(1, list.size());
Map<String, Object> item = list.get(0);
String errMsg = (String) item.get("lastErrorMessage");
String remark = (String) item.get("remark");
assertFalse("Password must not be leaked in error message", errMsg.contains("PlaintextSecret123"));
assertFalse("AppSecret must not be leaked in error message", errMsg.contains("TopSecretValue"));
assertFalse("Token must not be leaked in remark", remark.contains("raw_token_xyz"));
assertTrue("Sensitive text must be replaced with [PROTECTED]", errMsg.contains("[PROTECTED]"));
}
/**
* 测试手动对账接口:继续遵循相同前置条件校验,绝不提供绕过前置条件的强开权限
*/
@Test
public void testManualReconcile_RespectsPrerequisites()
{
// 1. 未满足条件学员 -> WAITING_FOR_PREREQUISITE
AjaxResult r1 = controller.manualReconcile(999L);
assertEquals(200, r1.get("code"));
ReconciliationResult res1 = (ReconciliationResult) r1.get("data");
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, res1.getDecision());
// 2. 满足条件学员 -> GRANT_REQUIRED
AjaxResult r2 = controller.manualReconcile(111L);
assertEquals(200, r2.get("code"));
ReconciliationResult res2 = (ReconciliationResult) r2.get("data");
assertEquals(ReconciliationDecision.GRANT_REQUIRED, res2.getDecision());
}
/**
* 安全基线红线断言:
* 1. 控制器中严禁存在任何名为 remoteUnlock 或类似远程应急开锁方法
* 2. 控制器类所有公开方法均无密码明文入参或返回
*/
@Test
public void testSecurityReview_ZeroRemoteUnlockMethod()
{
for (Method m : TrainAccessManagementController.class.getDeclaredMethods()) {
String name = m.getName().toLowerCase();
assertFalse("Remote unlock is strictly prohibited in controller: " + name,
name.contains("remoteunlock") || name.contains("remote_unlock") || name.contains("forceunlock"));
}
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.set(target, value);
}
}