|
|
@@ -0,0 +1,136 @@
|
|
|
+package com.payment.platform.module.payment.unified.client;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.fasterxml.jackson.databind.node.NullNode;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 统一支付签名器(spec §4.2 / Task 3 brief)。
|
|
|
+ *
|
|
|
+ * <p>向量均用 {@code md5sum} 在 UTF-8 原文上独立算过,不是从实现里回抄的。
|
|
|
+ * 排序规则按 spec §4.2 的 ASCII 顺序;另有一条「两序一致」测试锁住
|
|
|
+ * 「对真实字段集,ASCII 与大小写不敏感两种排序输出相同」这一前提。
|
|
|
+ */
|
|
|
+class UnifiedSignerTest {
|
|
|
+
|
|
|
+ private static final String SECRET = "secret123";
|
|
|
+
|
|
|
+ /** 测试用裸 ObjectMapper:上游报文不是 SNAKE_CASE,不能用应用全局实例。 */
|
|
|
+ private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void sign_matchesMeasuredVector() {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ p.put("subject", "测试");
|
|
|
+ p.put("amount", 100);
|
|
|
+ p.put("mchNo", "M1000");
|
|
|
+ p.put("appId", "A1");
|
|
|
+ assertEquals("C91D24D1C2C70EBF89F0DF58E7979DC9", UnifiedSigner.sign(p, SECRET));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void blankValues_areSkipped() {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ p.put("a", 1);
|
|
|
+ p.put("b", "");
|
|
|
+ p.put("c", null);
|
|
|
+ assertEquals("AFFDCC88244C83F871BFE4854BE9C1A5", UnifiedSigner.sign(p, "k"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notification_ignoresSignAndMatchesVector() {
|
|
|
+ Map<String, String> p = new LinkedHashMap<>();
|
|
|
+ p.put("state", "2");
|
|
|
+ p.put("payOrderId", "P1");
|
|
|
+ p.put("mchNo", "M1000");
|
|
|
+ p.put("sign", "IGNORED");
|
|
|
+ assertEquals("81948D582EFE91AB607513B35AA50C04",
|
|
|
+ UnifiedSigner.sign(p, SECRET));
|
|
|
+ // 验签比对的是上游送来的 sign 本身,占位串 "IGNORED" 不可能等于算出的摘要;
|
|
|
+ // 换成正确签名必须通过,换错密钥必须拒绝。
|
|
|
+ assertFalse(UnifiedSigner.verifyNotification(p, SECRET));
|
|
|
+ p.put("sign", "81948D582EFE91AB607513B35AA50C04");
|
|
|
+ assertTrue(UnifiedSigner.verifyNotification(p, SECRET));
|
|
|
+ assertFalse(UnifiedSigner.verifyNotification(p, "other"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void emptyParams_signsKeyOnly() {
|
|
|
+ assertEquals("3D18F19D605404495C924CF600E489C6",
|
|
|
+ UnifiedSigner.sign(new LinkedHashMap<>(), SECRET));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void responseData_nestedObjectUsesCompactJson() throws Exception {
|
|
|
+ JsonNode data = MAPPER.readTree(
|
|
|
+ "{\"state\":\"2\",\"channelRetMsg\":{\"channelErrCode\":\"E1\"}}");
|
|
|
+ assertTrue(UnifiedSigner.verifyData(data, "12448BEA270AC8E2D2A8BBA7803B77BC", SECRET));
|
|
|
+ assertFalse(UnifiedSigner.verifyData(data, "00000000000000000000000000000000", SECRET));
|
|
|
+ assertFalse(UnifiedSigner.verifyData(data, null, SECRET));
|
|
|
+ assertFalse(UnifiedSigner.verifyData(data, "", SECRET));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void emptyData_withBlankSign_passes() throws Exception {
|
|
|
+ assertTrue(UnifiedSigner.verifyData(null, null, SECRET));
|
|
|
+ assertTrue(UnifiedSigner.verifyData(NullNode.getInstance(), " ", SECRET));
|
|
|
+ assertTrue(UnifiedSigner.verifyData(MAPPER.readTree("{}"), null, SECRET));
|
|
|
+ assertFalse(UnifiedSigner.verifyData(MAPPER.readTree("{}"), "ABC", SECRET));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void verifyNotification_missingSign_returnsFalse() {
|
|
|
+ Map<String, String> p = new LinkedHashMap<>();
|
|
|
+ p.put("state", "2");
|
|
|
+ assertFalse(UnifiedSigner.verifyNotification(p, SECRET));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void asciiAndCaseInsensitiveOrdersAgreeForRealFieldSet() {
|
|
|
+ // spec §4.2 列出的真实请求字段集。两边排序结果相同是本实现的成立前提;
|
|
|
+ // 将来新增首字母大小写与 ASCII 次序相左的字段时,这条会先红。
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ p.put("amount", "100");
|
|
|
+ p.put("appId", "A1");
|
|
|
+ p.put("body", "b");
|
|
|
+ p.put("channelExtra", "{}");
|
|
|
+ p.put("clientIp", "127.0.0.1");
|
|
|
+ p.put("currency", "cny");
|
|
|
+ p.put("extParam", "e");
|
|
|
+ p.put("mchNo", "M1000");
|
|
|
+ p.put("mchOrderNo", "O1");
|
|
|
+ p.put("notifyUrl", "https://example.test/notify");
|
|
|
+ p.put("reqTime", "20261020120000");
|
|
|
+ p.put("returnUrl", "https://example.test/return");
|
|
|
+ p.put("signType", "MD5");
|
|
|
+ p.put("subject", "s");
|
|
|
+ p.put("version", "1.0");
|
|
|
+ p.put("wayCode", "WX_JSAPI");
|
|
|
+
|
|
|
+ assertEquals(canonicalUnder(p, String.CASE_INSENSITIVE_ORDER), UnifiedSigner.canonical(p));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String canonicalUnder(Map<String, Object> params, Comparator<String> order) {
|
|
|
+ List<String> keys = new ArrayList<>(params.keySet());
|
|
|
+ keys.sort(order);
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (String key : keys) {
|
|
|
+ if (sb.length() > 0) {
|
|
|
+ sb.append('&');
|
|
|
+ }
|
|
|
+ sb.append(key).append('=').append(params.get(key));
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+}
|