Преглед на файлове

docs(进件): T6 补 null/空文件名 - 客户端守卫收 null 后不拦就漏成 500

Task 2 的 fix round 2 把 fileName == null 纳入客户端守卫(原实现显式跳过 null),
客户端抛 IllegalArgumentException = 程序员错误语义。T6 若不自己拦,
用户传一个没带 filename 的部件就吃 500。

null 是真实可达的,不是假想:T6 的 fileName 是三-B 的 controller 从
MultipartFile.getOriginalFilename() 取的,而那个 API 的契约就是可空的。

补:测试行 uploadImage_nullOrBlankFileName_rejected(含 verifyNoInteractions)、
实现要点「null/空/全空白 ⇒ 400,且必须先于扩展名检查」、变异 M-9(把该检查挪到
扩展名之后 ⇒ 红,且写明是哪种红)。
alphaH преди 3 седмици
родител
ревизия
cb95f8573f
променени са 1 файла, в които са добавени 9 реда и са изтрити 0 реда
  1. 9 0
      .claude/plan/2026-09-22-lsy-onboarding-A.md

+ 9 - 0
.claude/plan/2026-09-22-lsy-onboarding-A.md

@@ -442,6 +442,7 @@ Run: `... -Dtest=LsyOnboardingServiceTest`
 | `uploadImage_emptyBytes_rejected` | 传 0 字节 ⇒ `BusinessException(400, ...)` |
 | `uploadImage_disallowedExtension_rejected` | `evil.exe` / `x.jpg.php` / `x`(无后缀)⇒ 400。`a.JPG`(大写)⇒ **通过**(扩展名匹配不区分大小写,这条钉住那个 `toLowerCase`) |
 | `uploadImage_fileNameWithPathSeparator_rejected` | `../../etc/passwd.jpg` / `a\\b.jpg` ⇒ 400 |
+| `uploadImage_nullOrBlankFileName_rejected` | `null` / `""` / `"   "` ⇒ `BusinessException(400, ...)`,且 **`verifyNoInteractions(client)`**。**★ 这条现在是硬性的**:Task 2 的 fix round 2 把 `fileName == null` 也纳入了客户端守卫,客户端会抛 `IllegalArgumentException` —— 你若不自己拦,它就以 **500** 漏出去(见实现要点)|
 | `uploadImage_controlCharInFileName_rejected` | `a\r\nX-Evil: 1.jpg` / `a\nb.jpg` / `a\u0000b.jpg` / `a\tb.jpg` ⇒ 400,且**一次上游调用都没发出**(`verifyNoInteractions(client)`)。**★ 钉的是 2026-09-22 实测出的部件头注入**(见实现要点)|
 | `uploadImage_ordinaryFilename_accepted` | **正对照**:`工资表 2026-09.jpg`(中文 + 空格)⇒ **通过**。没有这条,一把过宽的控制字符锁会静默拒掉所有真实上传,而上面那条照样绿 |
 | `uploadImage_fileNameLengthCapped` | 超过上游 `img_name` 能接受的长度(文档**没写**上限 ⇒ **不设**上游长度上限),只断言我们**不**擅自截断:长文件名原样送出去。**★ 这条是「不做」的钉子**,防止后来者顺手加个 `substring(0,50)` |
@@ -466,6 +467,13 @@ Run: `... -Dtest=LsyOnboardingServiceTest`
     **程序员错误**语义,直接漏出去就是 500;而这里的文件名来自**上传方**,是**用户输入**,
     必须变成 `BusinessException(400, 中文名)`。**两层都要,异常类型不同,理由不同。**
   - **不要**把这条写成「上游要求」—— 上游文档没提这个,这是**我们**的护栏(同 `uploadMaxBytes` 的写法)。
+- **★ 文件名 `null` / 空串 / 全空白 ⇒ 400**(**必须先于**扩展名检查,否则 `null` 会先在扩展名那段炸成 NPE)。
+  为什么现在必须加:Task 2 的 fix round 2 把 `fileName == null` 纳入客户端守卫后,
+  客户端抛的是 `IllegalArgumentException`(程序员错误语义)—— 你这里不拦,用户就吃 **500**。
+  与上面那条控制字符同理:**两层都要,异常类型不同**。
+  `null` 是真实可达的:本方法的 `fileName` 是调用方给的 `String`,而三-B 的 controller
+  多半从 `MultipartFile.getOriginalFilename()` 取 —— **那个 API 的契约就是可空的**
+  (上游不传 filename 时它为 null)。所以这不是假想路径。
 - **正对照(必须有)**:普通文件名含中文与空格(如 `工资表 2026-09.jpg`)**必须通过**。
   只测「坏名字被拒」发现不了一把过宽的锁 —— 那会把真实上传全拒掉。
 - 复用 Task 2 的 `postMultipart`:`path = "/api/info/upload/img"`、`fileFieldName = "img_file"`。
@@ -488,6 +496,7 @@ Run: `... -Dtest=LsyOnboardingServiceTest`
 | M-6(无害):把白名单常量从 `Set.of` 换成 `List.of` | **仍绿** |
 | M-7:去掉控制字符校验 | `uploadImage_controlCharInFileName_rejected` **红** |
 | M-8:把控制字符校验写成「过宽」(连中文/空格也拒) | `uploadImage_ordinaryFilename_accepted` **红**(**★ 这条变异是给锁验宽度的**:只验窄不验宽,过宽的锁照样通过全部拒绝用例)|
+| M-9:把 `null` / 空串检查**挪到扩展名检查之后** | `uploadImage_nullOrBlankFileName_rejected` **红**(会是 NPE 而不是 400 —— 报告里写明是哪种红)|
 
 - [ ] **Step 6: Commit** —— `feat(进件): 图片上传 - 本地大小与类型护栏在出站之前`