diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java index fe6499c3d..1357839d9 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java @@ -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()); } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java index 75c9cc1fe..9a22093ae 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java @@ -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")); + } + } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java index fea6c4e13..f8d7d610c 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java @@ -40,6 +40,7 @@ public class MockDmsServer { private final Set invalidatedTokens = ConcurrentHashMap.newKeySet(); private volatile boolean simulateReadTimeout = false; + private volatile boolean simulateEmptyResponse = false; private volatile int tokenTtlSeconds = 7200; private final Map 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); diff --git a/build_ui/tests/unit/authFeedback.spec.mjs b/build_ui/tests/unit/authFeedback.spec.mjs new file mode 100644 index 000000000..04cdfce57 --- /dev/null +++ b/build_ui/tests/unit/authFeedback.spec.mjs @@ -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!');