package com.yau.digitalrmb.institutionidentity; import com.jayway.jsonpath.JsonPath; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierOperationLogEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierOperationLogMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierTrainingErrorEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierTrainingErrorMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionPrivateKeyCipher; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionSm2KeyEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionSm2KeyMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; import com.yau.digitalrmb.security.application.JwtTokenService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.jdbc.core.JdbcTemplate; import java.util.List; import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") class InstitutionIdentifierControllerTest { private static final String BASE_PATH = "/api/v1/institution-identifiers"; private static final String USER_ID = "00000000-0000-0000-0000-000000009001"; @Autowired private MockMvc mvc; @Autowired private JwtTokenService jwtTokenService; @Autowired private InstitutionIdentifierApplicationMapper applicationMapper; @Autowired private InstitutionIdentifierOperationLogMapper operationLogMapper; @Autowired private InstitutionIdentifierTrainingErrorMapper trainingErrorMapper; @Autowired private InstitutionSm2KeyMapper keyMapper; @Autowired private InstitutionPrivateKeyCipher privateKeyCipher; @Autowired private InstitutionKeyService keyService; @Autowired private StuModuleScoreDetailsMapper scoreDetailsMapper; @Autowired private JdbcTemplate jdbcTemplate; private String authorization; private String keyAuthorization; private String tokenWithoutSubjectClaims; @BeforeEach void setUp() { jdbcTemplate.update("DELETE FROM currency_generation_verification_operation_log"); jdbcTemplate.update("DELETE FROM currency_generation_verification"); jdbcTemplate.update("DELETE FROM currency_generation_request_operation_log"); jdbcTemplate.update("DELETE FROM currency_generation_request"); jdbcTemplate.update("DELETE FROM institution_identifier_training_error"); jdbcTemplate.update("DELETE FROM institution_identifier_operation_log"); jdbcTemplate.update("DELETE FROM institution_identifier_application"); jdbcTemplate.update("DELETE FROM institution_sm2_key_audit"); jdbcTemplate.update("DELETE FROM institution_sm2_key"); jdbcTemplate.update("DELETE FROM stu_module_score_details"); keyAuthorization = "Bearer " + jwtTokenService.issueFor(USER_ID, 1001L, 2001L, "institution-key-tester", Collections.singleton("STUDENT")).accessToken(); authorization = keyAuthorization; tokenWithoutSubjectClaims = "Bearer " + jwtTokenService.issueFor("institution-api-tester").accessToken(); InstitutionKeySubject subject = new InstitutionKeySubject(USER_ID, 1001L, 2001L); keyService.commercialBankPublicKey(subject, "institution-key-tester"); keyService.centralBankPublicKey(subject, "institution-key-tester"); } @Test void resetTrainingLogicallyDeletesStepDataButPreservesKeysAndAllowsRestart() throws Exception { digest("BKCHCNBJ20991"); Integer keyCountBefore = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_sm2_key WHERE deleted = FALSE", Integer.class); mvc.perform(post(BASE_PATH + "/training/reset") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.reset").value(true)) .andExpect(jsonPath("$.data.keysPreserved").value(true)); Integer activeApplications = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_identifier_application WHERE user_id = ? AND school_id = ? AND class_id = ? AND deleted = FALSE", Integer.class, USER_ID, 1001L, 2001L); Integer deletedApplications = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_identifier_application WHERE user_id = ? AND school_id = ? AND class_id = ? AND deleted = TRUE", Integer.class, USER_ID, 1001L, 2001L); Integer keyCountAfter = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_sm2_key WHERE deleted = FALSE", Integer.class); assertThat(activeApplications).isZero(); assertThat(deletedApplications).isEqualTo(1); assertThat(keyCountAfter).isEqualTo(keyCountBefore); digest("BKCHCNBJ20992"); mvc.perform(get(BASE_PATH + "/pages/commercial-bank-application") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.bankCode").value("BKCHCNBJ20992")) .andExpect(jsonPath("$.data.status").value("DIGESTED")); } @Test void repeatedDigestUpdatesLatestRoundInsteadOfCreatingNewApplication() throws Exception { digest("BKCHCNBJ20995"); Long firstId = jdbcTemplate.queryForObject( "SELECT id FROM institution_identifier_application WHERE user_id = ? AND deleted = FALSE", Long.class, USER_ID); digest("BKCHCNBJ20996"); Integer activeCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_identifier_application WHERE user_id = ? AND deleted = FALSE", Integer.class, USER_ID); Long currentId = jdbcTemplate.queryForObject( "SELECT id FROM institution_identifier_application WHERE user_id = ? AND deleted = FALSE", Long.class, USER_ID); String currentBankCode = jdbcTemplate.queryForObject( "SELECT bank_code FROM institution_identifier_application WHERE id = ?", String.class, currentId); assertThat(activeCount).isEqualTo(1); assertThat(currentId).isEqualTo(firstId); assertThat(currentBankCode).isEqualTo("BKCHCNBJ20996"); } @Test void latestRoundBlocksFallbackToOlderRoundWithExpectedStatus() throws Exception { digest("BKCHCNBJ20993"); digest("BKCHCNBJ20994"); String privateKey = keyService.commercialBankKey( new InstitutionKeySubject(USER_ID, 1001L, 2001L), "institution-key-tester").getPrivateKey(); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message", org.hamcrest.Matchers.containsString("当前最新一轮状态:SIGNED"))); } @Test void pageQueriesReturnFixedStructureAndNullForUnavailableApplicationFields() throws Exception { mvc.perform(get(BASE_PATH + "/pages/commercial-bank-application") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.applicationId").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.status").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.bankCode").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.commercialBankSecondPrivateKey") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))); mvc.perform(get(BASE_PATH + "/pages/central-bank-verification") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.applicationId").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.originalText").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.h1").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.commercialBankSecondPublicKey") .value(org.hamcrest.Matchers.matchesPattern("04[0-9A-F]{128}"))) .andExpect(jsonPath("$.data.centralBankFirstPrivateKey") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))); digest("BKCHCNBJ20105"); mvc.perform(get(BASE_PATH + "/pages/commercial-bank-application") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("DIGESTED")) .andExpect(jsonPath("$.data.bankCode").value("BKCHCNBJ20105")) .andExpect(jsonPath("$.data.timestamp").value("20260730143218")) .andExpect(jsonPath("$.data.originalText") .value("BKCHCNBJ20105|20260730143218")) .andExpect(jsonPath("$.data.digest").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.bankSignature").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.applicationMessage").value(org.hamcrest.Matchers.nullValue())); } @Test void bankAndCentralKeyEndpointsPersistEncryptedPrivateKeysForTokenSubject() throws Exception { String bankResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank") .header("Authorization", keyAuthorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.keyId").value(InstitutionKeyService.BANK_SECOND_KEY)) .andExpect(jsonPath("$.data.owner").value("COMMERCIAL_BANK")) .andExpect(jsonPath("$.data.userId").value(USER_ID)) .andExpect(jsonPath("$.data.schoolId").value(1001)) .andExpect(jsonPath("$.data.classId").value(2001)) .andExpect(jsonPath("$.data.publicKey").value(org.hamcrest.Matchers.matchesPattern("04[0-9A-F]{128}"))) .andExpect(jsonPath("$.data.privateKey").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andReturn().getResponse().getContentAsString(); mvc.perform(post(BASE_PATH + "/keys/central-bank") .header("Authorization", keyAuthorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.keyId").value(InstitutionKeyService.CENTRAL_FIRST_KEY)) .andExpect(jsonPath("$.data.owner").value("CENTRAL_BANK")) .andExpect(jsonPath("$.data.publicKey").value(org.hamcrest.Matchers.matchesPattern("04[0-9A-F]{128}"))) .andExpect(jsonPath("$.data.privateKey").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))); List keys = keyMapper.selectList(null); assertThat(keys).hasSize(2); String returnedPrivateKey = JsonPath.read(bankResponse, "$.data.privateKey"); InstitutionSm2KeyEntity bankKey = keys.stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); assertThat(bankKey.getEncryptedPrivateKey()).isNotEqualTo(returnedPrivateKey); assertThat(privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey())).isEqualTo(returnedPrivateKey); } @Test void allInstitutionTrainingTablesHaveLogicalDeleteAndMapperDeleteHidesRows() throws Exception { String[] tables = { "institution_identifier_application", "institution_identifier_operation_log", "institution_identifier_training_error", "institution_sm2_key", "institution_sm2_key_audit", "currency_generation_request", "currency_generation_request_operation_log", "currency_generation_verification", "currency_generation_verification_operation_log" }; for (String table : tables) { Integer columnCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE LOWER(TABLE_NAME) = ? AND LOWER(COLUMN_NAME) = 'deleted'", Integer.class, table); assertThat(columnCount).as(table + " must have deleted column").isEqualTo(1); } mvc.perform(post(BASE_PATH + "/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"bankCode\":\"BKCHCNBJ20100|20260730143218\"}")) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG\"}")) .andExpect(status().isBadRequest()); InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0); assertThat(trainingErrorMapper.deleteById(error.getId())).isEqualTo(1); assertThat(trainingErrorMapper.selectById(error.getId())).isNull(); Boolean deleted = jdbcTemplate.queryForObject( "SELECT deleted FROM institution_identifier_training_error WHERE id = ?", Boolean.class, error.getId()); assertThat(deleted).isTrue(); } @Test void signAcceptsCommercialBankSecondPrivateKeyReturnedByKeyEndpoint() throws Exception { mvc.perform(post(BASE_PATH + "/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"bankCode\":\"BKCHCNBJ20101|20260730143218\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.digest").value( org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.length()").value(1)); InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey()); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.signature").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(1)); } @Test void signRejectsWrongPrivateKeyWithoutPersistingPrivateKeyValue() throws Exception { mvc.perform(post(BASE_PATH + "/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"bankCode\":\"BKCHCNBJ20102|20260730143218\"}")) .andExpect(status().isOk()); String malformedPrivateKey = "1173B8C5C558BAE5C2C9EB89FD5C30BCB7DA8B478B"; mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + malformedPrivateKey + "\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "签名必须选择商业银行第二私钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); String wrongPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001"; mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + wrongPrivateKey + "\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第2次错误"))); InstitutionIdentifierApplicationEntity application = applicationMapper.selectOne(null); assertThat(application.getScoringCriteria()).isEqualTo(2); List errors = trainingErrorMapper.selectList(null); assertThat(errors).hasSize(2); assertThat(errors).extracting(InstitutionIdentifierTrainingErrorEntity::getErrorType, InstitutionIdentifierTrainingErrorEntity::getProvidedKeyId) .containsOnly( org.assertj.core.groups.Tuple.tuple("WRONG_PRIVATE_KEY", null), org.assertj.core.groups.Tuple.tuple("WRONG_PRIVATE_KEY", null)); } @Test void logicalDeleteIsScopedToTokenSubjectAndAllowsRegeneration() throws Exception { String firstResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank") .header("Authorization", keyAuthorization)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); Number firstRecordId = JsonPath.read(firstResponse, "$.data.recordId"); keyService.logicalDelete(new InstitutionKeySubject(USER_ID, 1001L, 2001L), InstitutionKeyService.BANK_SECOND_KEY, "institution-key-tester"); Integer deleted = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_sm2_key WHERE id = ? AND deleted = TRUE", Integer.class, firstRecordId.longValue()); assertThat(deleted).isEqualTo(1); String regeneratedResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank") .header("Authorization", keyAuthorization)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); Number regeneratedRecordId = JsonPath.read(regeneratedResponse, "$.data.recordId"); assertThat(regeneratedRecordId.longValue()).isNotEqualTo(firstRecordId.longValue()); assertThat(keyMapper.selectList(null)).hasSize(2); } @Test void keyEndpointRejectsTokenWithoutSchoolAndClassClaims() throws Exception { mvc.perform(post(BASE_PATH + "/keys/commercial-bank") .header("Authorization", tokenWithoutSubjectClaims)) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.message").value("Token缺少userId")); } @Test void protectedStepRejectsMissingJwt() throws Exception { mvc.perform(post(BASE_PATH + "/steps/digest") .contentType(MediaType.APPLICATION_JSON) .content("{\"bankCode\":\"BKCHCNBJ20001|20260730143218\"}")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); } @Test void signWithoutCurrentDigestedRoundReturnsResourceNotFoundAndDoesNotCreateErrorRecord() throws Exception { mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG\"}")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "当前没有待签名的本轮机构标识数据"))); assertThat(applicationMapper.selectList(null)).isEmpty(); assertThat(trainingErrorMapper.selectList(null)).isEmpty(); assertThat(operationLogMapper.selectList(null)).isEmpty(); } @Test void wrongKeysIncreaseScoringCriteriaAndRecordErrorSequence() throws Exception { long applicationId = digest("BKCHCNBJ20004"); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG_PRIVATE_KEY\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"ANOTHER_WRONG_KEY\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第2次错误"))); InstitutionIdentifierApplicationEntity application = applicationMapper.selectById(applicationId); assertThat(application.getStatus()).isEqualTo("DIGESTED"); assertThat(application.getScoringCriteria()).isEqualTo(2); assertThat(trainingErrorMapper.selectList(null)) .filteredOn(error -> error.getApplicationId().equals(applicationId)) .extracting(InstitutionIdentifierTrainingErrorEntity::getErrorSequence, InstitutionIdentifierTrainingErrorEntity::getProvidedKeyId) .containsExactly( org.assertj.core.groups.Tuple.tuple(1, null), org.assertj.core.groups.Tuple.tuple(2, null)); StuModuleScoreDetailsEntity score = onlyScoreDetail(); assertThat(score.getMoudule()).isEqualTo("中国人民银行生成数字货币"); assertThat(score.getLearningProjects()).isEqualTo("实验实训"); assertThat(score.getSerialNumber()).isEqualTo(3); assertThat(score.getCompletionStatus()).isEqualTo("2"); assertThat(score.getScoringCriteria()).isEqualTo("0.5"); assertThat(score.getSchedule()).isZero(); assertThat(score.getScoreProject()).isZero(); } @Test void errorsAccumulateOnLatestRoundWhenDigestIsResubmitted() throws Exception { digest("BKCHCNBJ20006"); mvc.perform(post(BASE_PATH + "/steps/sign").header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG_ONE\"}")) .andExpect(status().isBadRequest()); mvc.perform(post(BASE_PATH + "/steps/sign").header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG_TWO\"}")) .andExpect(status().isBadRequest()); assertThat(onlyScoreDetail().getCompletionStatus()).isEqualTo("2"); digest("BKCHCNBJ20006"); mvc.perform(post(BASE_PATH + "/steps/sign").header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG_AFTER_RESUBMIT\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第3次错误"))); StuModuleScoreDetailsEntity score = onlyScoreDetail(); assertThat(score.getCompletionStatus()).isEqualTo("3"); assertThat(score.getSchedule()).isZero(); assertThat(score.getScoreProject()).isZero(); } @Test void digestResubmissionKeepsSameApplicationAndTrainingRound() throws Exception { long firstApplicationId = digest("BKCHCNBJ20005"); long secondApplicationId = digest("BKCHCNBJ20005"); assertThat(secondApplicationId).isEqualTo(firstApplicationId); InstitutionIdentifierApplicationEntity current = applicationMapper.selectById(firstApplicationId); assertThat(current.getTrainingRound()).isEqualTo(1); assertThat(current.getScoringCriteria()).isZero(); Integer activeCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM institution_identifier_application WHERE user_id = ? AND deleted = FALSE", Integer.class, USER_ID); assertThat(activeCount).isEqualTo(1); } @Test void completeApiWorkflowUsesCurrentTrainingRoundAndPersistsIssuedResult() throws Exception { long applicationId = digest("BKCHCNBJ20003"); mvc.perform(get(BASE_PATH + "/applications/commercial-bank") .header("Authorization", authorization)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "商业银行第二私钥签名成功后才会出现待确认数据"))); InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey()); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.signature").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(1)); mvc.perform(get(BASE_PATH + "/applications/commercial-bank") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value(1)) .andExpect(jsonPath("$.data.statusText").value("待确认")) .andExpect(jsonPath("$.data.applicationStatus").value("SIGNED")) .andExpect(jsonPath("$.data.institutionIdentifier") .value(org.hamcrest.Matchers.nullValue())); mvc.perform(post(BASE_PATH + "/steps/package") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("PACKAGED")); mvc.perform(post(BASE_PATH + "/steps/send") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("SUBMITTED")) .andExpect(jsonPath("$.data.sentToCentralBank").value(true)); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"publicKey\":\"" + bankPublicKey() + "\",\"digest\":\"" + applicationMapper.selectById(applicationId).getDigest() + "\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("VERIFIED")) .andExpect(jsonPath("$.data.verificationKeyId") .value(InstitutionKeyService.BANK_SECOND_KEY)) .andExpect(jsonPath("$.data.signatureValid").value(true)) .andExpect(jsonPath("$.data.digestMatches").value(true)) .andExpect(jsonPath("$.data.h1").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.h2").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.digestComparison").value("H1 = H2")) .andExpect(jsonPath("$.data.verificationProcess").value(org.hamcrest.Matchers.containsString( "重新进行SM3哈希运算得到H1"))) .andExpect(jsonPath("$.data.signatureConclusion").value(org.hamcrest.Matchers.containsString( "身份真实性验证通过"))) .andExpect(jsonPath("$.data.integrityConclusion").value(org.hamcrest.Matchers.containsString( "数据完整性验证通过"))); mvc.perform(get(BASE_PATH + "/applications/verification-process") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.applicationStatus").value("VERIFIED")) .andExpect(jsonPath("$.data.originalText").value("BKCHCNBJ20003|20260730143218")) .andExpect(jsonPath("$.data.h1").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.h2").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.digestComparison").value("H1 = H2")) .andExpect(jsonPath("$.data.signatureValid").value(true)) .andExpect(jsonPath("$.data.digestMatches").value(true)) .andExpect(jsonPath("$.data.verified").value(true)) .andExpect(jsonPath("$.data.identityVerificationTool").value("商业银行第二公钥")) .andExpect(jsonPath("$.data.integrityVerificationTool").value("SM3")); mvc.perform(post(BASE_PATH + "/applications/confirmation") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.signature").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(1)); mvc.perform(get(BASE_PATH + "/applications/final-identifier") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.applicationStatus").value("ISSUED")) .andExpect(jsonPath("$.data.feedbackStatus").value(1)) .andExpect(jsonPath("$.data.feedbackStatusText").value("待反馈")) .andExpect(jsonPath("$.data.institutionIdentifier") .value(org.hamcrest.Matchers.startsWith("ORG_"))) .andExpect(jsonPath("$.data.fullInstitutionIdentifier") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))); mvc.perform(get(BASE_PATH + "/applications/commercial-bank") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value(1)) .andExpect(jsonPath("$.data.statusText").value("待确认")) .andExpect(jsonPath("$.data.applicationStatus").value("ISSUED")) .andExpect(jsonPath("$.data.institutionIdentifier") .value(org.hamcrest.Matchers.nullValue())); mvc.perform(post(BASE_PATH + "/currency-requests/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"amount\":1000.00,\"deliveryNodeCode\":\"SYS_DC_001\"," + "\"institutionIdentifier\":\"ORG_000000000000\"," + "\"requestTimestamp\":\"20260730153218\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "机构标识尚未反馈到商业银行端"))); mvc.perform(post(BASE_PATH + "/applications/feedback") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.applicationStatus").value("FEEDBACKED")) .andExpect(jsonPath("$.data.feedbackStatus").value(2)) .andExpect(jsonPath("$.data.feedbackStatusText").value("已反馈")); mvc.perform(get(BASE_PATH + "/applications/commercial-bank") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value(2)) .andExpect(jsonPath("$.data.statusText").value("已确认")) .andExpect(jsonPath("$.data.applicationStatus").value("FEEDBACKED")) .andExpect(jsonPath("$.data.institutionIdentifier") .value(org.hamcrest.Matchers.startsWith("ORG_"))) .andExpect(jsonPath("$.data.fullInstitutionIdentifier") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))); String issuedOrg = jdbcTemplate.queryForObject( "SELECT institution_identifier FROM institution_identifier_application WHERE id = ?", String.class, applicationId); mvc.perform(post(BASE_PATH + "/currency-requests/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"amount\":1589.85,\"deliveryNodeCode\":\"SYS_DC_001\"," + "\"institutionIdentifier\":\"" + issuedOrg + "\"," + "\"requestTimestamp\":\"20260730153218\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.digest") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.length()").value(1)); Number currencyRequestId = jdbcTemplate.queryForObject( "SELECT id FROM currency_generation_request WHERE institution_identifier = ? AND deleted = FALSE", Number.class, issuedOrg); java.util.Map storedRequest = jdbcTemplate.queryForMap( "SELECT institution_identifier, amount, delivery_node_code, request_timestamp, " + "request_original_text, request_digest FROM currency_generation_request WHERE id = ?", currencyRequestId.longValue()); assertThat(storedRequest.get("institution_identifier")).isEqualTo(issuedOrg); assertThat(storedRequest.get("amount").toString()).isEqualTo("1589.85"); assertThat(storedRequest.get("delivery_node_code")).isEqualTo("SYS_DC_001"); assertThat(storedRequest.get("request_timestamp")).isEqualTo("20260730153218"); assertThat(storedRequest.get("request_original_text")).isEqualTo( issuedOrg + "|1589.85|SYS_DC_001|20260730153218"); assertThat(storedRequest.get("request_digest").toString()).matches("[0-9A-F]{64}"); mvc.perform(get(BASE_PATH + "/currency-requests/page") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.institutionIdentifier") .value(storedRequest.get("institution_identifier"))) .andExpect(jsonPath("$.data.amount").value(1589.85)) .andExpect(jsonPath("$.data.deliveryNodeCode").value("SYS_DC_001")) .andExpect(jsonPath("$.data.requestTimestamp") .value(storedRequest.get("request_timestamp"))) .andExpect(jsonPath("$.data.requestOriginalText") .value(storedRequest.get("request_original_text"))) .andExpect(jsonPath("$.data.requestDigest") .value(storedRequest.get("request_digest"))); mvc.perform(post(BASE_PATH + "/currency-requests/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"amount\":2000.00,\"deliveryNodeCode\":\"SYS_DC_002\"," + "\"institutionIdentifier\":\"" + issuedOrg + "\"," + "\"requestTimestamp\":\"20260730160000\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.digest") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) .andExpect(jsonPath("$.data.length()").value(1)); Number repeatedRequestId = jdbcTemplate.queryForObject( "SELECT id FROM currency_generation_request WHERE institution_identifier = ? AND deleted = FALSE", Number.class, issuedOrg); assertThat(repeatedRequestId.longValue()).isEqualTo(currencyRequestId.longValue()); Integer requestCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM currency_generation_request WHERE institution_identifier = ? AND deleted = FALSE", Integer.class, issuedOrg); assertThat(requestCount).isEqualTo(1); mvc.perform(post(BASE_PATH + "/currency-requests/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + "0000000000000000000000000000000000000000000000000000000000000001" + "\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "当前实训主体的商业银行第二私钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "本次实训第1次错误"))); assertThat(onlyScoreDetail().getCompletionStatus()).isEqualTo("1"); mvc.perform(post(BASE_PATH + "/currency-requests/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.signature") .value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]+"))) .andExpect(jsonPath("$.data.length()").value(1)); mvc.perform(post(BASE_PATH + "/currency-requests/steps/package") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("PACKAGED")) .andExpect(jsonPath("$.data.requestMessage").value(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers.containsString("\"requestId\":\"REQ_20260730_"), org.hamcrest.Matchers.containsString("\"orgId\":\"" + issuedOrg + "\""), org.hamcrest.Matchers.containsString("\"amount\":\"2000.00\""), org.hamcrest.Matchers.containsString("\"systemId\":\"SYS_DC_002\""), org.hamcrest.Matchers.containsString("\"timestamp\":\"20260730160000\""), org.hamcrest.Matchers.containsString("\"digest\":"), org.hamcrest.Matchers.containsString("\"signature\":")))) .andExpect(jsonPath("$.data.requestMessage").value(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("\"originalText\"")))) .andExpect(jsonPath("$.data.requestMessage").value(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("\"keyId\"")))); jdbcTemplate.update("UPDATE currency_generation_request SET deleted = TRUE WHERE id = ?", currencyRequestId.longValue()); mvc.perform(get(BASE_PATH + "/currency-requests/page") .header("Authorization", authorization)) .andExpect(status().isNotFound()); InstitutionIdentifierApplicationEntity feedbacked = applicationMapper.selectById(applicationId); assertThat(feedbacked.getStatus()).isEqualTo("FEEDBACKED"); assertThat(feedbacked.getDigest()).matches("[0-9A-F]{64}"); assertThat(feedbacked.getBankSignature()).isNotBlank(); assertThat(feedbacked.getCentralBankSignature()).isNotBlank(); assertThat(feedbacked.getInstitutionIdentifier()).startsWith("ORG_"); StuModuleScoreDetailsEntity score = onlyScoreDetail(); assertThat(score.getCompletionStatus()).isEqualTo("1"); assertThat(score.getSchedule()).isEqualTo(11.11); assertThat(score.getScoreProject()).isEqualTo(11.05); List logs = operationLogMapper.selectList(null); assertThat(logs).filteredOn(log -> log.getApplicationId().equals(applicationId)) .extracting(InstitutionIdentifierOperationLogEntity::getOperation) .containsExactly("PREPARE", "DIGEST", "SIGN", "PACKAGE", "SEND", "DECRYPT_VERIFY", "CONFIRM", "FEEDBACK"); } @Test void centralBankDecryptionVerificationRejectsWrongPublicKeyAndKeepsPackagedStatus() throws Exception { long applicationId = digest("BKCHCNBJ20104"); InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey()); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/package") .header("Authorization", authorization)) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/send") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("SUBMITTED")) .andExpect(jsonPath("$.data.sentToCentralBank").value(true)); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"publicKey\":\"" + centralBankPublicKey() + "\",\"digest\":\"" + applicationMapper.selectById(applicationId).getDigest() + "\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "解密验证必须选择当前主体备案的商业银行第二公钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); InstitutionIdentifierApplicationEntity packaged = applicationMapper.selectById(applicationId); assertThat(packaged.getStatus()).isEqualTo("SUBMITTED"); InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0); assertThat(error.getErrorType()).isEqualTo("WRONG_PUBLIC_KEY"); assertThat(error.getOperationStep()).isEqualTo("DECRYPT_VERIFY"); assertThat(error.getProvidedKeyId()).isNull(); } @Test void decryptionVerificationRejectsWrongDigestAndUpdatesScoreDetail() throws Exception { long applicationId = digest("BKCHCNBJ20105"); InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey()); mvc.perform(post(BASE_PATH + "/steps/sign").header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/package").header("Authorization", authorization)) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/send").header("Authorization", authorization)) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"publicKey\":\"" + bankPublicKey() + "\",\"digest\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("摘要"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); assertThat(applicationMapper.selectById(applicationId).getStatus()).isEqualTo("SUBMITTED"); InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0); assertThat(error.getErrorType()).isEqualTo("WRONG_DIGEST"); assertThat(error.getOperationStep()).isEqualTo("DECRYPT_VERIFY"); StuModuleScoreDetailsEntity score = onlyScoreDetail(); assertThat(score.getCompletionStatus()).isEqualTo("1"); assertThat(score.getScoreProject()).isEqualTo(0.0); } @Test void confirmationValidatesCentralBankPrivateKeyOnlyWhenProvided() throws Exception { long applicationId = advanceToVerified("BKCHCNBJ20103"); mvc.perform(post(BASE_PATH + "/applications/confirmation") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "签名确权必须使用中央银行第一私钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); InstitutionIdentifierApplicationEntity verified = applicationMapper.selectById(applicationId); assertThat(verified.getStatus()).isEqualTo("VERIFIED"); assertThat(verified.getScoringCriteria()).isEqualTo(1); InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0); assertThat(error.getErrorType()).isEqualTo("WRONG_PRIVATE_KEY"); assertThat(error.getProvidedKeyId()).isNull(); InstitutionSm2KeyEntity centralBankKey = keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.CENTRAL_FIRST_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); String privateKey = privateKeyCipher.decrypt(centralBankKey.getEncryptedPrivateKey()); mvc.perform(post(BASE_PATH + "/applications/confirmation") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.signature").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(1)); } private long advanceToVerified(String bankCode) throws Exception { long applicationId = digest(bankCode); InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new); String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey()); mvc.perform(post(BASE_PATH + "/steps/sign") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + privateKey + "\"}")) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/package") .header("Authorization", authorization)) .andExpect(status().isOk()); mvc.perform(post(BASE_PATH + "/steps/send") .header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("SUBMITTED")) .andExpect(jsonPath("$.data.sentToCentralBank").value(true)); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"publicKey\":\"" + bankPublicKey() + "\",\"digest\":\"" + applicationMapper.selectById(applicationId).getDigest() + "\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("VERIFIED")); return applicationId; } private String bankPublicKey() { return keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new).getPublicKey(); } private String centralBankPublicKey() { return keyMapper.selectList(null).stream() .filter(key -> InstitutionKeyService.CENTRAL_FIRST_KEY.equals(key.getKeyId())) .findFirst().orElseThrow(AssertionError::new).getPublicKey(); } private StuModuleScoreDetailsEntity onlyScoreDetail() { List details = scoreDetailsMapper.selectList(null); assertThat(details).hasSize(1); return details.get(0); } private long digest(String bankCode) throws Exception { mvc.perform(post(BASE_PATH + "/steps/digest") .header("Authorization", authorization) .contentType(MediaType.APPLICATION_JSON) .content("{\"bankCode\":\"" + bankCode + "|20260730143218\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.digest").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(1)) .andExpect(jsonPath("$.data.applicationId").doesNotExist()) .andExpect(jsonPath("$.data.status").doesNotExist()) .andExpect(jsonPath("$.data.algorithm").doesNotExist()); return applicationMapper.selectList(null).stream() .max(java.util.Comparator.comparing(InstitutionIdentifierApplicationEntity::getCreatedAt) .thenComparing(InstitutionIdentifierApplicationEntity::getId)) .orElseThrow(() -> new AssertionError("机构标识申请记录未创建")).getId(); } }