合并门禁:将DMS真实接口探测结果集成至质量验证基线
This commit is contained in:
commit
758fd79e16
139 files changed
+18058
-483
No files matched your search
+245
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-4
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -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());
|
||||
}
|
||||
}
|
||||
+87
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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());
|
||||
}
|
||||
}
|
||||
+56
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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"));
|
||||
}
|
||||
}
|
||||
+118
@@ -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"));
|
||||
}
|
||||
}
|
||||
+269
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user