测试门禁:覆盖失败提示与空响应重发保护

This commit is contained in:
wangzhibiao committed 2026-09-21 16:56:06 +08:00
1 parent 63914e2499
commit 481c998e82
4 files changed
+401

No files matched your search

@@ -19,6 +19,7 @@ import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
import com.ruoyi.train.integration.dms.orchestration.BatchReconciliationResult;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationDecision;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationItemVO;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
@@ -474,6 +475,146 @@ public class DmsConsistencyHardeningPackageA1Test {
assertEquals(TrainExternalOperation.STATUS_CREATED, op.getOperationStatus());
}
/**
* MAJ-03 行为测试 1: 2 dispatched + 1 failed -> PARTIAL_FAILURE -> success=false
*/
@Test
public void testMaj03_Behavior1_TwoDispatchedOneFailed_PartialFailure_SuccessFalse() {
BatchReconciliationResult batch = new BatchReconciliationResult(101L);
batch.addItem(new ReconciliationItemVO(1L, 11L, "REVOKE", "DISPATCHED", "ok"));
batch.addItem(new ReconciliationItemVO(2L, 12L, "REVOKE", "DISPATCHED", "ok"));
batch.addItem(new ReconciliationItemVO(3L, 13L, "REVOKE", "FAILED", "vendor error"));
batch.setDispatched(2);
batch.setFailed(1);
batch.computeOutcome();
assertEquals(BatchReconciliationResult.OUTCOME_PARTIAL_FAILURE, batch.getOutcome());
assertFalse(batch.isSuccess());
ReconciliationResult recon = ReconciliationResult.batch(batch);
assertFalse("ReconciliationResult.batch() success 必须为 false", recon.isSuccess());
assertTrue(recon.isExternalInvoked());
}
/**
* MAJ-03 行为测试 2: 全部 failed -> ALL_FAILED -> success=false
*/
@Test
public void testMaj03_Behavior2_AllFailed_SuccessFalse() {
BatchReconciliationResult batch = new BatchReconciliationResult(101L);
batch.addItem(new ReconciliationItemVO(1L, 11L, "REVOKE", "FAILED", "error 1"));
batch.addItem(new ReconciliationItemVO(2L, 12L, "REVOKE", "FAILED", "error 2"));
batch.setFailed(2);
batch.computeOutcome();
assertEquals(BatchReconciliationResult.OUTCOME_ALL_FAILED, batch.getOutcome());
assertFalse(batch.isSuccess());
ReconciliationResult recon = ReconciliationResult.batch(batch);
assertFalse("ReconciliationResult.batch() success 必须为 false", recon.isSuccess());
assertFalse(recon.isExternalInvoked());
}
/**
* MAJ-03 行为测试 3: manualRequired > 0 -> REQUIRES_MANUAL_SETTLEMENT -> success=false
*/
@Test
public void testMaj03_Behavior3_ManualRequired_RequiresManualSettlement_SuccessFalse() {
BatchReconciliationResult batch = new BatchReconciliationResult(101L);
batch.addItem(new ReconciliationItemVO(1L, 11L, "REVOKE", "MANUAL_REQUIRED", "AMBIGUOUS_EXTERNAL_CREDENTIAL"));
batch.setManualRequired(1);
batch.computeOutcome();
assertEquals(BatchReconciliationResult.OUTCOME_REQUIRES_MANUAL_SETTLEMENT, batch.getOutcome());
assertFalse(batch.isSuccess());
ReconciliationResult recon = ReconciliationResult.batch(batch);
assertFalse("ReconciliationResult.batch() success 必须为 false", recon.isSuccess());
assertFalse(recon.isExternalInvoked());
}
/**
* MAJ-03 行为测试 4: queued only -> WAITING_FOR_DMS -> success=true -> externalInvoked=false
*/
@Test
public void testMaj03_Behavior4_QueuedOnly_WaitingForDms_SuccessTrue_ExternalInvokedFalse() {
BatchReconciliationResult batch = new BatchReconciliationResult(101L);
batch.addItem(new ReconciliationItemVO(1L, 11L, "REVOKE", "QUEUED", "DMS disabled"));
batch.setQueued(1);
batch.computeOutcome();
assertEquals(BatchReconciliationResult.OUTCOME_WAITING_FOR_DMS, batch.getOutcome());
assertTrue(batch.isSuccess());
assertFalse(batch.isExternalInvoked());
ReconciliationResult recon = ReconciliationResult.batch(batch);
assertTrue("ReconciliationResult.batch() success 必须为 true", recon.isSuccess());
assertFalse("ReconciliationResult.batch() externalInvoked 必须为 false", recon.isExternalInvoked());
}
/**
* MAJ-03 行为测试 5: dispatched only -> IN_PROGRESS -> success=true
*/
@Test
public void testMaj03_Behavior5_DispatchedOnly_InProgress_SuccessTrue() {
BatchReconciliationResult batch = new BatchReconciliationResult(101L);
batch.addItem(new ReconciliationItemVO(1L, 11L, "REVOKE", "DISPATCHED", "ok"));
batch.addItem(new ReconciliationItemVO(2L, 12L, "REVOKE", "DISPATCHED", "ok"));
batch.setDispatched(2);
batch.computeOutcome();
assertEquals(BatchReconciliationResult.OUTCOME_IN_PROGRESS, batch.getOutcome());
assertTrue(batch.isSuccess());
assertTrue(batch.isExternalInvoked());
ReconciliationResult recon = ReconciliationResult.batch(batch);
assertTrue("ReconciliationResult.batch() success 必须为 true", recon.isSuccess());
assertTrue("ReconciliationResult.batch() externalInvoked 必须为 true", recon.isExternalInvoked());
}
/**
* EMPTY_RESPONSE ambiguity 行为测试:
* 厂商 Mutation 返回 HTTP 200,但 body 为空字符串/null,
* 异常被归类为不确定性异常 (DmsTransportException),
* 必须进入 operation = AMBIGUOUS, auth = ERROR,
* 第二次 Issue 必须被重发守卫阻断,且 gateway total call 仍然 = 1,严禁 FAILED 导致重发!
*/
@Test
public void testEmptyResponse_MutationClassifiedAsAmbiguous_BlocksReissue() {
TrainDormAuth auth = new TrainDormAuth();
auth.setAuthId(authSeq.incrementAndGet());
auth.setAccommodationId(201L);
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
auth.setCredentialType("ONLINE_PASSWORD");
dormAuthDb.put(201L, auth);
gateway.shouldThrowEmptyResponse = true;
// 第一次下发:由于空响应,抛出 DmsTransportException
IssueResultVO result1 = orchestrator.issueAuthorization(auth.getAuthId(), "admin");
assertNotNull(result1);
assertEquals("第一次下发由于空响应必须为 AMBIGUOUS_EXTERNAL_MUTATION",
"AMBIGUOUS_EXTERNAL_MUTATION", result1.getStatus());
TrainExternalOperation op1 = operationService.selectLatestGrantOperationByDoorAuthId(auth.getAuthId());
assertNotNull(op1);
assertEquals("operation_status 必须为 AMBIGUOUS",
TrainExternalOperation.STATUS_AMBIGUOUS, op1.getOperationStatus());
TrainDormAuth updatedAuth = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId());
assertEquals("auth_status 必须为 ERROR",
TrainDormAuth.AUTH_STATUS_ERROR, updatedAuth.getAuthStatus());
assertEquals("网关已被调用 1 次", 1, gateway.issueCallCount.get());
// 第二次下发:必须被重发守卫阻断,严禁重新调用外部网关!
IssueResultVO result2 = orchestrator.issueAuthorization(auth.getAuthId(), "admin");
assertNotNull(result2);
assertEquals("第二次下发必须被拦截为 AMBIGUOUS_EXTERNAL_MUTATION",
"AMBIGUOUS_EXTERNAL_MUTATION", result2.getStatus());
assertEquals("网关总调用次数仍然必须为 1,绝对禁止第二次调用",
1, gateway.issueCallCount.get());
}
/**
* T9: 同一宿舍同一有效床位(status in 0,1)重复分配:
* Service 必须预检拦截并抛出明确业务错误 BED_ALREADY_OCCUPIED
@@ -598,6 +739,7 @@ public class DmsConsistencyHardeningPackageA1Test {
final AtomicInteger revokeCallCount = new AtomicInteger(0);
boolean shouldThrowTimeout = false;
boolean shouldRejectBusiness = false;
boolean shouldThrowEmptyResponse = false;
Long failOnKeyId = null;
@Override
@@ -605,6 +747,9 @@ public class DmsConsistencyHardeningPackageA1Test {
String plaintextPassword, String validStart, String validUntil) {
issueCallCount.incrementAndGet();
if (shouldThrowEmptyResponse) {
throw new DmsTransportException("EMPTY_RESPONSE: Received null response from DMS password creation");
}
if (shouldThrowTimeout) {
throw new DmsTransportException("SocketTimeoutException: Read timed out after HTTP write", new SocketTimeoutException());
}
@@ -4,6 +4,7 @@ import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
import com.ruoyi.train.integration.dms.config.DmsProperties;
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl;
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
@@ -114,4 +115,38 @@ public class DmsPasswordAdapterTest {
assertNotNull(resp);
assertEquals(Long.valueOf(99002L), resp.getOperationId());
}
/**
* 行为测试:密码下发 (Mutation) 返回 HTTP 200 + body="",
* 必须抛出 DmsTransportException (EMPTY_RESPONSE),视作外部结果未决,绝对不可当做普通业务失败
*/
@Test
public void testCreateOnlinePassword_EmptyResponseBody_ThrowsDmsTransportException() {
mockServer.setSimulateEmptyResponse(true);
try {
credentialGateway.issueOnlinePassword(
5001L, "张三", "13900001111", "654321", "2026-09-16 10:00:00", "2026-09-17 18:00:00"
);
fail("Should throw DmsTransportException on empty response body");
} catch (DmsTransportException e) {
assertTrue("Exception message should contain EMPTY_RESPONSE", e.getMessage().contains("EMPTY_RESPONSE"));
}
}
/**
* 行为测试:密码删除 (Mutation) 返回 HTTP 200 + body="",
* 必须抛出 DmsTransportException (EMPTY_RESPONSE),视作外部结果未决
*/
@Test
public void testDeleteOnlinePassword_EmptyResponseBody_ThrowsDmsTransportException() {
mockServer.setSimulateEmptyResponse(true);
try {
credentialGateway.revokeOnlinePassword(88001L);
fail("Should throw DmsTransportException on empty response body");
} catch (DmsTransportException e) {
assertTrue("Exception message should contain EMPTY_RESPONSE", e.getMessage().contains("EMPTY_RESPONSE"));
}
}
}
@@ -40,6 +40,7 @@ public class MockDmsServer {
private final Set<String> invalidatedTokens = ConcurrentHashMap.newKeySet();
private volatile boolean simulateReadTimeout = false;
private volatile boolean simulateEmptyResponse = false;
private volatile int tokenTtlSeconds = 7200;
private final Map<Long, OperationStatusEntry> operationStatusMap = new ConcurrentHashMap<>();
@@ -83,6 +84,10 @@ public class MockDmsServer {
this.simulateReadTimeout = simulate;
}
public void setSimulateEmptyResponse(boolean simulate) {
this.simulateEmptyResponse = simulate;
}
public void setTokenTtlSeconds(int ttl) {
this.tokenTtlSeconds = ttl;
}
@@ -245,6 +250,11 @@ public class MockDmsServer {
passwordCreatePostCounter.incrementAndGet();
if (simulateEmptyResponse) {
sendResponse(exchange, 200, "");
return;
}
if (simulateReadTimeout) {
// 模拟网络读超时 (sleep 超过客户端 10000ms 或测试配置的短超时)
try {
@@ -278,6 +288,11 @@ public class MockDmsServer {
passwordDeletePostCounter.incrementAndGet();
if (simulateEmptyResponse) {
sendResponse(exchange, 200, "");
return;
}
if (simulateReadTimeout) {
try {
Thread.sleep(1200);
+206
View File
@@ -0,0 +1,206 @@
import assert from 'node:assert';
import { resolveRevokeFeedback, resolveIssueFeedback, resolveReconcileFeedback } from '../../src/views/train/auth/authFeedback.js';
console.log('--- Testing resolveRevokeFeedback ---');
// 1. REVOKE: FAILED (decision == FAILED || success == false)
{
const res = resolveRevokeFeedback({ decision: 'FAILED', success: false, reason: '设备离线超时' });
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.message, '设备离线超时');
assert.strictEqual(res.message.includes('成功'), false);
}
{
const res = resolveRevokeFeedback({ decision: 'REVOKE_REQUIRED', success: false, reason: 'DMS rejected delete' });
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.message, 'DMS rejected delete');
assert.strictEqual(res.message.includes('成功'), false);
}
// 2. REVOKE: WAITING/queued (decision == REVOKE_REQUIRED && success=true && !externalInvoked)
{
const res = resolveRevokeFeedback({
decision: 'REVOKE_REQUIRED',
success: true,
externalInvoked: false,
reason: 'DMS disabled; authorization has externalAuthId 123, revoke queued as CREATED'
});
assert.strictEqual(res.type, 'warning');
assert.strictEqual(res.message.includes('当前未执行外部注销') || res.message.includes('queued'), true);
assert.strictEqual(res.message.includes('注销成功'), false);
}
// 3. REVOKE: IN_PROGRESS / Dispatched (decision == REVOKE_REQUIRED && success=true && externalInvoked=true)
{
const res = resolveRevokeFeedback({
decision: 'REVOKE_REQUIRED',
success: true,
externalInvoked: true,
reason: 'Password delete accepted by DMS; awaiting poller'
});
assert.strictEqual(res.type, 'info');
assert.strictEqual(res.message, '注销请求已提交,等待设备确认。');
}
// 4. REVOKE: NO_ACTION (decision == NO_ACTION && success=true)
{
const res = resolveRevokeFeedback({
decision: 'NO_ACTION',
success: true,
reason: 'Draft auth never issued to external system; marked REVOKED locally'
});
assert.strictEqual(res.type, 'info');
assert.strictEqual(res.message, 'Draft auth never issued to external system; marked REVOKED locally');
}
console.log('--- Testing resolveIssueFeedback ---');
// 1. ISSUE: ACCEPTED with credential
{
const res = resolveIssueFeedback({
status: 'ACCEPTED',
message: '凭据已提交下发,设备确认后生效。',
oneTimePlaintextCredential: '829471'
});
assert.strictEqual(res.type, 'success');
assert.strictEqual(res.openCredentialDialog, true);
assert.strictEqual(res.message, '凭据已提交下发,设备确认后生效。');
}
// 2. ISSUE: ACCEPTED without credential
{
const res = resolveIssueFeedback({
status: 'ACCEPTED',
message: '凭据已提交下发,设备确认后生效。',
oneTimePlaintextCredential: null
});
assert.strictEqual(res.type, 'success');
assert.strictEqual(res.openCredentialDialog, false);
}
// 3. ISSUE: WAITING_FOR_PREREQUISITE (MISSING_STUDENT_PHONE)
{
const res = resolveIssueFeedback({
status: 'WAITING_FOR_PREREQUISITE',
message: 'MISSING_STUDENT_PHONE: 学员主档缺失有效手机号,暂无法下发门禁'
});
assert.strictEqual(res.type, 'warning');
assert.strictEqual(res.openCredentialDialog, false);
assert.strictEqual(res.message.includes('MISSING_STUDENT_PHONE'), true);
}
// 4. ISSUE: WAITING_FOR_PREREQUISITE (INVALID_VALIDITY_WINDOW)
{
const res = resolveIssueFeedback({
status: 'WAITING_FOR_PREREQUISITE',
message: 'INVALID_VALIDITY_WINDOW: 培训计划结束时间缺失或早于生效开始时间,无法确定凭据有效期窗口'
});
assert.strictEqual(res.type, 'warning');
assert.strictEqual(res.openCredentialDialog, false);
assert.strictEqual(res.message.includes('INVALID_VALIDITY_WINDOW'), true);
}
// 5. ISSUE: WAITING_FOR_PREREQUISITE (DMS_DISABLED)
{
const res = resolveIssueFeedback({
status: 'WAITING_FOR_PREREQUISITE',
message: 'DMS_DISABLED: 共享运行环境门禁对接未启用 (dms.enabled=false),无法向外部厂商下发门禁凭据'
});
assert.strictEqual(res.type, 'warning');
assert.strictEqual(res.openCredentialDialog, false);
assert.strictEqual(res.message.includes('DMS_DISABLED'), true);
}
// 6. ISSUE: AMBIGUOUS (AMBIGUOUS_EXTERNAL_MUTATION)
{
const res = resolveIssueFeedback({
status: 'AMBIGUOUS_EXTERNAL_MUTATION',
message: 'AMBIGUOUS_EXTERNAL_MUTATION: 上一次下发结果无法确认,禁止重复下发,请先核对厂商侧凭据。(MANUAL_VENDOR_RECONCILIATION_REQUIRED)'
});
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.openCredentialDialog, false);
assert.strictEqual(res.message.includes('禁止重复下发'), true);
}
// 7. ISSUE: FAILED
{
const res = resolveIssueFeedback({
status: 'FAILED',
message: '门禁凭据下发失败 (厂商拒绝): 门锁离线'
});
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.openCredentialDialog, false);
assert.strictEqual(res.message.includes('门禁凭据下发失败'), true);
}
console.log('--- Testing resolveReconcileFeedback ---');
// 1. RECONCILE: ALL_SETTLED
{
const res = resolveReconcileFeedback({
batchResult: { outcome: 'ALL_SETTLED' },
success: true,
reason: 'All settled'
});
assert.strictEqual(res.type, 'success');
assert.strictEqual(res.message, 'All settled');
}
// 2. RECONCILE: IN_PROGRESS
{
const res = resolveReconcileFeedback({
batchResult: { outcome: 'IN_PROGRESS' },
success: true
});
assert.strictEqual(res.type, 'info');
assert.strictEqual(res.message.includes('指令已派发至外部设备'), true);
}
// 3. RECONCILE: WAITING_FOR_DMS
{
const res = resolveReconcileFeedback({
batchResult: { outcome: 'WAITING_FOR_DMS' },
success: true
});
assert.strictEqual(res.type, 'warning');
assert.strictEqual(res.message.includes('等待DMS恢复后处理'), true);
assert.strictEqual(res.message.includes('成功'), false);
}
// 4. RECONCILE: PARTIAL_FAILURE (必须为 error,严禁 success,严禁“对账执行成功”)
{
const res = resolveReconcileFeedback({
batchResult: { outcome: 'PARTIAL_FAILURE' },
success: false,
reason: '2 dispatched, 1 failed'
});
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.message.includes('部分处理失败'), true);
assert.strictEqual(res.message.includes('对账执行成功'), false);
}
// 5. RECONCILE: ALL_FAILED (必须为 error,严禁 success)
{
const res = resolveReconcileFeedback({
batchResult: { outcome: 'ALL_FAILED' },
success: false,
reason: 'All failed'
});
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.message.includes('对账处理失败') || res.message.includes('执行失败'), true);
assert.strictEqual(res.message.includes('对账执行成功'), false);
}
// 6. RECONCILE: REQUIRES_MANUAL_SETTLEMENT (必须为 error,明确需要人工核对厂商侧状态)
{
const res = resolveReconcileFeedback({
batchResult: { outcome: 'REQUIRES_MANUAL_SETTLEMENT' },
success: false,
reason: 'Manual check required'
});
assert.strictEqual(res.type, 'error');
assert.strictEqual(res.message.includes('需要人工核对厂商侧状态'), true);
assert.strictEqual(res.message.includes('对账执行成功'), false);
}
console.log('ALL FRONTEND FEEDBACK TESTS PASSED!');