|
|
@@ -1,5 +1,6 @@
|
|
|
package com.payment.platform.module.payment.leshouying.client;
|
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.config.LeshouyingHttpConfig;
|
|
|
import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
@@ -20,17 +21,21 @@ import org.springframework.web.client.RestTemplate;
|
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.concurrent.atomic.AtomicReference;
|
|
|
+import java.util.stream.Collectors;
|
|
|
|
|
|
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.assertThrows;
|
|
|
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.verifyNoInteractions;
|
|
|
import static org.mockito.Mockito.when;
|
|
|
|
|
|
/**
|
|
|
@@ -71,6 +76,18 @@ class LeshouyingClientMultipartTest {
|
|
|
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);
|
|
|
|
|
|
+ /**
|
|
|
+ * 正对照用的**合法**文件名:中文 + 空格。过宽的守卫(拒非 ASCII / 拒空格)会被它抓到。
|
|
|
+ */
|
|
|
+ private static final String LEGAL_CN_NAME = "工资表 2026-09.jpg";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 值原样上线的探针值:**首尾各带两个空格**。
|
|
|
+ * 普通名(如 {@code business-licence.jpg})对 trim 不敏感 —— 拿它测「值有没有被规范化」
|
|
|
+ * 是测不出来的,一个顺手 trim 的实现照样绿。要让这件事**可观测**,值本身必须对规范化敏感。
|
|
|
+ */
|
|
|
+ private static final String PADDED_IMG_NAME = " 2026-09-22 upload.jpg ";
|
|
|
+
|
|
|
private static final String OK_BODY =
|
|
|
"{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"img_url\":\"http://x/y.jpg\"}";
|
|
|
|
|
|
@@ -203,6 +220,148 @@ class LeshouyingClientMultipartTest {
|
|
|
*/
|
|
|
@Test
|
|
|
void postMultipart_wireFormat_filePartCarriesFilename() {
|
|
|
+ String wire = captureWire(Map.of("img_name", IMG_NAME), FILE_NAME);
|
|
|
+
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ 拒绝:三个**会进部件头**的入口含 ISO 控制字符 ⇒ {@link IllegalArgumentException},
|
|
|
+ * 且**一个字节都不出海**({@code verifyNoInteractions(restTemplate)})。
|
|
|
+ *
|
|
|
+ * <p>**判据是实测的**(真实 {@code FormHttpMessageConverter},探针见报告附录):
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code fileFieldName} 含 CRLF ⇒ 线上 {@code name="img\r\nX-Evil: 1"} —— 注入;</li>
|
|
|
+ * <li>{@code formFields} 的**键**含 CRLF ⇒ 线上 {@code name="na\r\nX-Evil: 1me"} —— 注入;</li>
|
|
|
+ * <li>{@code fileName} 含 CRLF ⇒ 线上 {@code filename="a\r\nX-Evil: 1.jpg"} —— 注入。</li>
|
|
|
+ * </ul>
|
|
|
+ * 三处的共同点是**值被拼进部件头**,于是一个 CR/LF 就凭空多出一行头部。
|
|
|
+ * 而 {@code formFields} 的**值**写在部件**体**里(由 {@code Content-Length} 界定),
|
|
|
+ * 不构成头部行 ⇒ **不校验**,见 {@link #postMultipart_formFieldValueGoesOnTheWireUnchanged}。
|
|
|
+ *
|
|
|
+ * <p>单 LF、tab、NUL 各自单列一条:CRLF 被拒**不代表**其余控制字符都被拒
|
|
|
+ * (判据是 {@link Character#isISOControl},要的是整族,不是只堵 CRLF 两个字面量)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_rejectsCrLfInFileFieldName() {
|
|
|
+ assertRejectsHeaderBound("fileFieldName", "img\r\n_file", FILE_NAME, Map.of("img_name", IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void postMultipart_rejectsCrLfInFormFieldKey() {
|
|
|
+ assertRejectsHeaderBound("formFields 的键", FILE_FIELD, FILE_NAME,
|
|
|
+ Map.of("na\r\nme", IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void postMultipart_rejectsCrLfInFileName() {
|
|
|
+ assertRejectsHeaderBound("fileName", FILE_FIELD, "a\r\nb.jpg", Map.of("img_name", IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void postMultipart_rejectsLoneLfInFileName() {
|
|
|
+ assertRejectsHeaderBound("fileName", FILE_FIELD, "a\nb.jpg", Map.of("img_name", IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void postMultipart_rejectsTabInFileName() {
|
|
|
+ assertRejectsHeaderBound("fileName", FILE_FIELD, "a\tb.jpg", Map.of("img_name", IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void postMultipart_rejectsNulInFileName() {
|
|
|
+ assertRejectsHeaderBound("fileName", FILE_FIELD, "a\u0000b.jpg", Map.of("img_name", IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 三个入口共用的拒绝断言:抛 IAE、消息**点名是哪个入口**、且**没有发生任何出站**
|
|
|
+ * (守卫在组实体之前 ⇒ mock 的 {@code restTemplate} 一次都没被碰过)。
|
|
|
+ */
|
|
|
+ private void assertRejectsHeaderBound(String entryLabel, String fileFieldName,
|
|
|
+ String fileName, Map<String, String> formFields) {
|
|
|
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> client.postMultipart(UPLOAD_PATH, formFields, fileFieldName, fileName, FILE_BYTES));
|
|
|
+
|
|
|
+ assertTrue(ex.getMessage().contains(entryLabel),
|
|
|
+ "异常消息要点名是哪个入口 —— 排障的人得知道去改哪一处,实际: " + ex.getMessage());
|
|
|
+ verifyNoInteractions(restTemplate);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 正对照:**合法**文件名必须通过 —— 中文 + 空格({@link #LEGAL_CN_NAME})。
|
|
|
+ *
|
|
|
+ * <p>只测「坏名字被拒」是**发现不了一把过宽的锁**的:一个「拒非 ASCII」或「拒空格」的实现
|
|
|
+ * 能让上面六条拒绝用例**全绿**,而真实上传会被全拒。这条是给那把锁**验宽度**的
|
|
|
+ * (变异 M-B 打的就是它)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_acceptsLegalChineseNameWithSpace() {
|
|
|
+ stubOk(OK_BODY);
|
|
|
+
|
|
|
+ Map<String, Object> resp = client.postMultipart(UPLOAD_PATH, Map.of("img_name", LEGAL_CN_NAME),
|
|
|
+ FILE_FIELD, LEGAL_CN_NAME, FILE_BYTES);
|
|
|
+
|
|
|
+ assertEquals("SUCCESS", resp.get("result_code"));
|
|
|
+ verify(restTemplate).postForEntity(
|
|
|
+ eq(BASE_URL + UPLOAD_PATH), any(HttpEntity.class), eq(String.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 钉住「表单字段的值**原样**上线」。
|
|
|
+ *
|
|
|
+ * <p>能骗过全部既有用例的实现:{@code signedPayload} 顺手对值做 trim / 规范化,
|
|
|
+ * 或 multipart 腿拿**另一份** map 去拼 body —— 上游收到的名字与调用方给的不一致。
|
|
|
+ * 既有用例接不住:签名用例的 verify 是拿**已发出**的部件重算的(按构造自洽),
|
|
|
+ * wire 用例只断言 {@code name="img_name"} **存在**、不断言它的值。
|
|
|
+ *
|
|
|
+ * <p>值**首尾带空格**是刻意的:普通名({@code business-licence.jpg})对 trim 不敏感,
|
|
|
+ * 拿它测「有没有被规范化」是测不出来的 —— 值必须本身就对规范化敏感(见 {@link #PADDED_IMG_NAME})。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postMultipart_formFieldValueGoesOnTheWireUnchanged() {
|
|
|
+ String wire = captureWire(Map.of("img_name", PADDED_IMG_NAME), FILE_NAME);
|
|
|
+
|
|
|
+ assertEquals(PADDED_IMG_NAME, partValueFromWire(wire, "img_name"),
|
|
|
+ "表单字段的值必须原样上线(不许 trim / 规范化),实际报文:\n" + wire);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 钉住上面那条 wireFormat 用例所依赖的**装置假设**:「生产的 {@code lsyRestTemplate}
|
|
|
+ * 用的是 {@code RestTemplate} 默认转换器表,那条用例与生产只差一个请求工厂」。
|
|
|
+ *
|
|
|
+ * <p>今天成立({@code LeshouyingHttpConfig.lsyRestTemplate()} 实测就是
|
|
|
+ * {@code new RestTemplate(factory)}),但此前**无人钉住**。具体会被这条接住的失败场景:
|
|
|
+ * 将来有人给该 bean 加 {@code setMessageConverters(...)},本类其余用例**全绿**,
|
|
|
+ * 而上传在运行期抛 {@code HttpMessageNotWritableException} —— 它会被
|
|
|
+ * {@link LeshouyingApiException} 的兜底分支包成 {@code NETWORK_ERROR},
|
|
|
+ * 于是一个「上传坏了」的症状读起来像网络故障,排查方向直接跑偏。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void lsyRestTemplateBean_usesDefaultConverterTable() {
|
|
|
+ List<Class<?>> defaults = converterTypes(new RestTemplate());
|
|
|
+
|
|
|
+ assertEquals(defaults, converterTypes(new LeshouyingHttpConfig().lsyRestTemplate()),
|
|
|
+ "生产 bean 的转换器表与 RestTemplate 默认表不一致 ⇒ 本类 wireFormat 用例的装置"
|
|
|
+ + "不再等同于生产(上传会变成 NETWORK_ERROR,见本方法 javadoc)");
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Class<?>> converterTypes(RestTemplate template) {
|
|
|
+ return template.getMessageConverters().stream()
|
|
|
+ .map(Object::getClass)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 用**真实** {@code RestTemplate}(默认转换器表,与生产的 {@code lsyRestTemplate} 只差请求工厂)
|
|
|
+ * 把请求真正序列化一遍,返回线上字节。装置的正身在此,两个字节级用例共用。
|
|
|
+ */
|
|
|
+ private String captureWire(Map<String, String> formFields, String fileName) {
|
|
|
AtomicReference<MockClientHttpRequest> captured = new AtomicReference<>();
|
|
|
ClientHttpRequestFactory capturingFactory = (uri, method) -> {
|
|
|
MockClientHttpRequest req = new MockClientHttpRequest(method, uri);
|
|
|
@@ -211,21 +370,30 @@ class LeshouyingClientMultipartTest {
|
|
|
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);
|
|
|
+ new LeshouyingClient(new RestTemplate(capturingFactory),
|
|
|
+ new LeshouyingSigner(), new LeshouyingResponseParser(), props)
|
|
|
+ .postMultipart(UPLOAD_PATH, formFields, FILE_FIELD, fileName, FILE_BYTES);
|
|
|
|
|
|
- String wire = captured.get().getBodyAsString(StandardCharsets.UTF_8);
|
|
|
+ return 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);
|
|
|
+ /**
|
|
|
+ * 从线上字节里解出某个字符串部件的**值**:部件头块以空行结束,值写到下一个
|
|
|
+ * {@code \r\n--boundary} 之前。解不出返回 null(断言会带着报文原文失败,不会静默)。
|
|
|
+ */
|
|
|
+ private String partValueFromWire(String wire, String partName) {
|
|
|
+ int at = wire.indexOf("name=\"" + partName + "\"");
|
|
|
+ if (at < 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ int bodyStart = wire.indexOf("\r\n\r\n", at);
|
|
|
+ if (bodyStart < 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ bodyStart += 4;
|
|
|
+ int bodyEnd = wire.indexOf("\r\n--", bodyStart);
|
|
|
+ return bodyEnd < 0 ? null : wire.substring(bodyStart, bodyEnd);
|
|
|
}
|
|
|
|
|
|
private void stubOk(String body) {
|