Hotfix: strip invalid XML control chars from field values (FR-16)

ผู้ใช้ paste ข้อความที่มี vertical tab (0x0B) เข้าฟอร์ม — XML 1.0 ห้าม
อักขระนี้ ทำให้ XmlWriter โยน ArgumentException ตอน save package และ
หลุดเป็น 500 stack เต็มจอ (catch เดิมครอบแค่ OpenXmlPackageException)

- DocxGenerationService.SanitizeForXml: ตัดอักขระที่ XML 1.0 ห้าม
  (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — คู่
  surrogate ครบ เช่น อีโมจิ คงไว้) ที่จุดเขียนค่าทั้งสองของ
  ReplaceSdtContent → ครอบทั้ง generate และ generate-draft
- Controllers: broad catch (ยกเว้น cancellation) → 502 ข้อความอ่านออก
  แทน 500 ดิบ สำหรับ exception ที่ไม่ได้ตั้งใจจาก OpenXML
- Test: Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500 (draft +
  generate v0) — reviewer พิสูจน์แล้วว่า fail จริงบนโค้ดก่อนแก้
- backend 85/85; QA reviewer PASS (1 round, minors 3: แก้ 1 ปฏิเสธ 2
  พร้อมเหตุผล — บันทึกใน acceptance-review.md)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 12:07:50 +07:00
parent 70f71f4c5c
commit b9b227926d
8 changed files with 96 additions and 7 deletions

View file

@ -89,7 +89,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
}
/// <summary>สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก)</summary>
private async Task<Guid> CreateThaiTemplateAsync() {
private async Task<Guid> CreateThaiTemplateAsync()
{
using var client = fixture.CreateClient();
using var create = await client.PostAsync(
"api/templates",
@ -191,6 +192,35 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
}
[Fact]
public async Task Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500()
{
// ผู้ใช้ paste ข้อความที่มีอักขระควบคุม (vertical tab 0x0B / form feed 0x0C) มาในค่า field —
// XML 1.0 ห้ามอักขระพวกนี้ เดิม XmlWriter โยน ArgumentException ตอน save ทั้ง package กลายเป็น 500
// ต้องตัดออกเงียบๆ แล้วสร้างไฟล์สำเร็จ (อักขระมองไม่เห็นอยู่แล้ว) — ครอบทั้ง draft และ generate v0
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "paste control chars");
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
{
["position"] = "วิศวกร\u000Bซอฟต์\u000Cแวร์", // VT/FF ฝังเป็น escape กันอักขระล่องหนหายระหว่างแก้ไขไฟล์
};
// ไฟล์ร่าง
using var draft = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
var link = (await draft.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
using var browser = new HttpClient();
var draftBytes = await (await browser.GetAsync(link.Url)).Content.ReadAsByteArrayAsync();
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position"));
// generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
using var download = await client.GetAsync(doc.DownloadUrl);
var docBytes = await download.Content.ReadAsByteArrayAsync();
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position"));
}
[Fact]
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
{