|
|
@@ -0,0 +1,260 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.client;
|
|
|
+
|
|
|
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.core.io.Resource;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.http.client.ClientHttpRequestFactory;
|
|
|
+import org.springframework.mock.http.client.MockClientHttpRequest;
|
|
|
+import org.springframework.mock.http.client.MockClientHttpResponse;
|
|
|
+import org.springframework.util.MultiValueMap;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.atomic.AtomicReference;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 钉住 `LeshouyingClient.postMultipart` —— 图片上传的 multipart 出站腿(上游文档页 196236572)。
|
|
|
+ *
|
|
|
+ * 【为什么值得单开一个测试类】它是**唯一**一条带文件部件的出站腿,也是唯一一条请求侧
|
|
|
+ * 与另两条腿分叉的腿:表单部件代替 JSON body,且上游明写 `img_file` **不参与签名**。
|
|
|
+ * 签名口径一旦在这条腿上走偏,失败形态是上游回一句笼统的「签名错误」——
|
|
|
+ * 排查方向会指向密钥而不是参数集。所以这里逐条钉住:签名的**构件**、文件部件的**文件名**、
|
|
|
+ * 请求的 **Content-Type**、以及末行的**信封**,最后再用一条线上字节级用例兜底。
|
|
|
+ *
|
|
|
+ * 【测试里没有、也不该有任何真实凭据】key 是占位串(签名器对 key 只做拼接、不校验形状),
|
|
|
+ * 所以「确实签了、且签的是 props 里那个 key」照样可钉,而机构密钥不必被抄进测试文件。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class LeshouyingClientMultipartTest {
|
|
|
+
|
|
|
+ private static final String BASE_URL = "https://test.gdyfsk.com/yfpay";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 路径只是桩用的占位串,**不代表上游真实接口名** —— 真实路径由文档给出,
|
|
|
+ * 本任务(客户端出站腿)不该把它固化在这里,否则它会变成第二个真相来源。
|
|
|
+ */
|
|
|
+ private static final String UPLOAD_PATH = "/api/uploadImg";
|
|
|
+
|
|
|
+ private static final String FILE_FIELD = "img_file";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ `img_name` 与 `fileName` **故意取不同的值**。
|
|
|
+ * 契约上这是两样东西:`img_name` 是上游要求的**表单字段**(图片的完整名称,带后缀),
|
|
|
+ * `fileName` 是 multipart 的文件名。只有把两者分开,「文件的名字不是签名构件」这句
|
|
|
+ * 才**可断言**(若两者用同一个字符串,`img_name` 会让那条断言必然为假 —— 见下面的注释)。
|
|
|
+ */
|
|
|
+ private static final String IMG_NAME = "business-licence.jpg";
|
|
|
+ private static final String FILE_NAME = "upload-20260922.jpg";
|
|
|
+
|
|
|
+ /** 文件内容用可检索的 ASCII 标记,才能断言「这段字节没有被当成签名构件」。 */
|
|
|
+ private static final String FILE_MARKER = "FAKE-JPEG-BODY-MUST-NOT-BE-SIGNED";
|
|
|
+ private static final byte[] FILE_BYTES = FILE_MARKER.getBytes(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ private static final String OK_BODY =
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"img_url\":\"http://x/y.jpg\"}";
|
|
|
+
|
|
|
+ @Mock private RestTemplate restTemplate;
|
|
|
+ private LeshouyingProperties props;
|
|
|
+ private LeshouyingClient client;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ props = new LeshouyingProperties();
|
|
|
+ props.setBaseUrl(BASE_URL);
|
|
|
+ props.setInstNo("900112");
|
|
|
+ // ★ 占位密钥,**故意不是**任何真实凭据(全局约束:凭据只打印长度、不落值)。
|
|
|
+ props.setKey("task-2-placeholder-key-not-a-credential");
|
|
|
+ client = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
|
|
|
+ new LeshouyingResponseParser(), props);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 这条钉的是**签名原文的构件集合**:只有字符串字段,没有文件。
|
|
|
+ *
|
|
|
+ * 上游规定 `img_file` 不参与签名,所以客户端必须做到两件事,它们是**两个不同的失败模式**,
|
|
|
+ * 由两组断言分别接住:
|
|
|
+ * <ol>
|
|
|
+ * <li>**签名算错**(把文件的内容/名字也拿去签名,但没当成表单字段发出去):
|
|
|
+ * 发出的 `sign` 就与「收到的那些字符串字段」对不上 ⇒ 断言 ① 红。</li>
|
|
|
+ * <li>**多发了字段**(把文件当成一个字符串部件塞进签名 map 与表单):
|
|
|
+ * `sign` 自洽(上游按同一份参数集重算也会得到同一个值),
|
|
|
+ * 所以只靠 ① **接不住** ⇒ 必须有断言 ②(表单里不许有 `img_file` 这个字符串部件)。</li>
|
|
|
+ * </ol>
|
|
|
+ * 第二个失败模式正是「把文件部件也塞进签名 map」这种改法的产物,也就是 M-1 那一次变异。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_signsOnlyFormFields_fileIsNotSigned() {
|
|
|
+ stubOk(OK_BODY);
|
|
|
+
|
|
|
+ client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
|
|
|
+ FILE_FIELD, FILE_NAME, FILE_BYTES);
|
|
|
+
|
|
|
+ Map<String, Object> stringParts = stringPartsOf(capturedBody());
|
|
|
+
|
|
|
+ assertEquals("900112", stringParts.get("inst_no"), "inst_no 必须自动补进签名原文");
|
|
|
+ assertNotNull(stringParts.get("sign"), "sign 必须自动补进签名原文");
|
|
|
+
|
|
|
+ // ① 签名必须能用 props 里那个 key 验回 —— 即「签的确实是这几个字符串部件」
|
|
|
+ assertTrue(new LeshouyingSigner().verify(stringParts, props.getKey()),
|
|
|
+ "字符串部件重算的签名不成立;实际部件名: " + stringParts.keySet());
|
|
|
+
|
|
|
+ // ② 文件**不得**作为字符串部件进入签名原文(M-1 的落点)
|
|
|
+ assertFalse(stringParts.containsKey(FILE_FIELD),
|
|
|
+ "文件部件不得进入签名 map:上游规定 img_file 不参与签名;实际部件名: "
|
|
|
+ + stringParts.keySet());
|
|
|
+ // ③ 文件的两个可识别特征都不该是签名原文里的值
|
|
|
+ assertFalse(stringParts.containsValue(FILE_NAME),
|
|
|
+ "multipart 的文件名不得出现在签名原文里;实际: " + stringParts);
|
|
|
+ assertTrue(stringParts.values().stream()
|
|
|
+ .noneMatch(v -> String.valueOf(v).contains(FILE_MARKER)),
|
|
|
+ "文件内容不得出现在签名原文里;实际: " + stringParts);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 钉住那个**匿名子类**:默认的 `ByteArrayResource.getFilename()` 返回 null,
|
|
|
+ * 不覆写就拿不到文件名 —— 而 `FormHttpMessageConverter` 判「这是不是文件部件」的唯一依据
|
|
|
+ * 就是 `part instanceof Resource ? part.getFilename() : null`(Spring 6.1.6 字节码)。
|
|
|
+ *
|
|
|
+ * <p>这条是**代理指标**(量的是我们交给框架的那个对象);真正决定上游收到什么的是
|
|
|
+ * {@link #postMultipart_wireFormat_filePartCarriesFilename()} 那条线上字节级用例。
|
|
|
+ * 两条都要:这一条在文件部件被换成别的类型时最先红,那一条在字节层面兜底。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_sendsFilePartWithRealFilename() {
|
|
|
+ stubOk(OK_BODY);
|
|
|
+
|
|
|
+ client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
|
|
|
+ FILE_FIELD, FILE_NAME, FILE_BYTES);
|
|
|
+
|
|
|
+ Object part = capturedBody().getFirst(FILE_FIELD);
|
|
|
+
|
|
|
+ assertInstanceOf(Resource.class, part,
|
|
|
+ "文件部件必须是 Resource(字符串部件拿不到文件名),实际: "
|
|
|
+ + (part == null ? "null" : part.getClass().getName()));
|
|
|
+ assertEquals(FILE_NAME, ((Resource) part).getFilename(),
|
|
|
+ "文件名没传到文件部件上 —— 默认 ByteArrayResource 返回 null");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 请求必须是 multipart;写成 JSON 或 form-urlencoded 时上游收不到文件部件。 */
|
|
|
+ @Test
|
|
|
+ void postMultipart_contentTypeIsMultipartFormData() {
|
|
|
+ stubOk(OK_BODY);
|
|
|
+
|
|
|
+ client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
|
|
|
+ FILE_FIELD, FILE_NAME, FILE_BYTES);
|
|
|
+
|
|
|
+ MediaType contentType = capturedEntity().getHeaders().getContentType();
|
|
|
+ assertNotNull(contentType, "请求必须带 Content-Type");
|
|
|
+ // 断言 startsWith 而不是 equals:真正的请求头由转换器补 boundary,我们只定类型。
|
|
|
+ assertTrue(contentType.toString().startsWith("multipart/form-data"),
|
|
|
+ "文件上传必须走 multipart,实际: " + contentType);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 信封必须是 `/api/*` 那一套。
|
|
|
+ *
|
|
|
+ * 桩回的报文里**只有** `result_code`(进件信封的形状),没有 `return_code`:
|
|
|
+ * 走 `parseApi` ⇒ 正常返回;误走 `parseV3` ⇒ 抛 PARSE_ERROR
|
|
|
+ * (`parseV3` 第一条分支就是「缺 return_code ⇒ 非 v3 响应」)。两种实现在返回值上直接可见。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_parsesApiEnvelope() {
|
|
|
+ stubOk(OK_BODY);
|
|
|
+
|
|
|
+ Map<String, Object> resp = client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
|
|
|
+ FILE_FIELD, FILE_NAME, FILE_BYTES);
|
|
|
+
|
|
|
+ assertEquals("SUCCESS", resp.get("result_code"));
|
|
|
+ assertEquals("http://x/y.jpg", resp.get("img_url"),
|
|
|
+ "img_url 是这条接口唯一的产出,取不到就等于上传白做");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ 线上字节级用例:本类唯一能证明「上游确实会收到一个**带 filename 的文件部件**」的断言。
|
|
|
+ *
|
|
|
+ * <p>上面那条 `sendsFilePartWithRealFilename` 量的是「我们交给框架的 Resource 上
|
|
|
+ * getFilename() 不为 null」—— 那是代理指标,中间还隔着转换器。这里换成**真实的
|
|
|
+ * `RestTemplate`**(默认转换器列表,与生产的 `lsyRestTemplate` 只差请求工厂)
|
|
|
+ * 接在一个捕获请求体的 `ClientHttpRequestFactory` 上,直接读它写出来的 multipart 字节。
|
|
|
+ *
|
|
|
+ * <p>顺带钉住两件只有字节层面才看得见的事:字符串字段**不带** filename 段
|
|
|
+ * (带了就不再是普通表单字段)、文件内容真的进了报文(不是只发了个空壳部件)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_wireFormat_filePartCarriesFilename() {
|
|
|
+ AtomicReference<MockClientHttpRequest> captured = new AtomicReference<>();
|
|
|
+ ClientHttpRequestFactory capturingFactory = (uri, method) -> {
|
|
|
+ MockClientHttpRequest req = new MockClientHttpRequest(method, uri);
|
|
|
+ req.setResponse(new MockClientHttpResponse(
|
|
|
+ OK_BODY.getBytes(StandardCharsets.UTF_8), HttpStatus.OK));
|
|
|
+ captured.set(req);
|
|
|
+ return req;
|
|
|
+ };
|
|
|
+ LeshouyingClient wireClient = new LeshouyingClient(new RestTemplate(capturingFactory),
|
|
|
+ new LeshouyingSigner(), new LeshouyingResponseParser(), props);
|
|
|
+
|
|
|
+ wireClient.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
|
|
|
+ FILE_FIELD, FILE_NAME, FILE_BYTES);
|
|
|
+
|
|
|
+ String wire = captured.get().getBodyAsString(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ assertTrue(wire.contains("Content-Disposition: form-data; name=\"" + FILE_FIELD
|
|
|
+ + "\"; filename=\"" + FILE_NAME + "\""),
|
|
|
+ "文件部件的 Content-Disposition 必须带 filename,实际报文:\n" + wire);
|
|
|
+ assertTrue(wire.contains("name=\"img_name\""), "字符串字段必须在报文里,实际报文:\n" + wire);
|
|
|
+ assertFalse(wire.contains("name=\"img_name\"; filename="),
|
|
|
+ "字符串字段被写成了文件部件(不该有 filename 段),实际报文:\n" + wire);
|
|
|
+ assertTrue(wire.contains(FILE_MARKER), "文件内容必须真的写进报文,实际报文:\n" + wire);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubOk(String body) {
|
|
|
+ when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>(body, HttpStatus.OK));
|
|
|
+ }
|
|
|
+
|
|
|
+ private HttpEntity<?> capturedEntity() {
|
|
|
+ ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
|
|
|
+ verify(restTemplate).postForEntity(
|
|
|
+ eq(BASE_URL + UPLOAD_PATH), captor.capture(), eq(String.class));
|
|
|
+ return captor.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private MultiValueMap<String, Object> capturedBody() {
|
|
|
+ return (MultiValueMap<String, Object>) capturedEntity().getBody();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 只挑字符串部件 —— 与出站侧「签名原文只由字符串字段构成」同一口径。 */
|
|
|
+ private Map<String, Object> stringPartsOf(MultiValueMap<String, Object> body) {
|
|
|
+ Map<String, Object> stringParts = new LinkedHashMap<>();
|
|
|
+ body.forEach((name, values) -> {
|
|
|
+ for (Object v : values) {
|
|
|
+ if (v instanceof String s) {
|
|
|
+ stringParts.put(name, s);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return stringParts;
|
|
|
+ }
|
|
|
+}
|