Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word)

การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe
ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/
file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน)
open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox +
backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic
คือ download-first

- Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) +
  OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey}
  — ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query),
  [AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition
  ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link
  แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง
- Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ
  best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป
  ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด)
- Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md
  กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต
- backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS
  (1 round, minors 4 แก้ครบ)

หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ
OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 13:39:23 +07:00
parent e0558e3cf4
commit ee4d8573ee
21 changed files with 340 additions and 145 deletions

View file

@ -25,8 +25,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
Assert.False(string.IsNullOrWhiteSpace(link.Url));
Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
using var browser = new HttpClient();
using var file = await browser.GetAsync(link.Url);
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
var fileBytes = await file.Content.ReadAsByteArrayAsync();
@ -70,8 +70,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
using var browser = new HttpClient();
using var file = await browser.GetAsync(link.Url);
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
var fileBytes = await file.Content.ReadAsByteArrayAsync();
@ -185,8 +185,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
using var browser = new HttpClient();
using var file = await browser.GetAsync(link.Url);
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
var fileBytes = await file.Content.ReadAsByteArrayAsync();
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
@ -210,8 +210,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
$"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();
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
var draftBytes = await (await anon.GetAsync(new Uri(link.Url).PathAndQuery)).Content.ReadAsByteArrayAsync();
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position"));
// generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint
@ -221,6 +221,32 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position"));
}
[Fact]
public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded()
{
// /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้)
// รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{objectKey} — ไม่มี query string เพราะ Word 16.101+
// บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิดหรือหมดอายุ → 404 พร้อมข้อความบอกกดเปิดใหม่
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token");
using var draft = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft",
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
var link = (await draft.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
Assert.Contains("/api/open/", link.Url);
Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string
Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO
// แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/)
var segments = new Uri(link.Url).AbsolutePath.Split('/');
segments[3] = "garbage-token";
using var anon = fixture.CreateAnonymousClient();
using var bad = await anon.GetAsync("/" + string.Join("/", segments.Skip(1)));
Assert.Equal(HttpStatusCode.NotFound, bad.StatusCode);
var body = await bad.Content.ReadAsStringAsync();
Assert.Contains("หมดอายุ", body);
}
[Fact]
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
{

View file

@ -113,6 +113,9 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
["Authentication:ValidAudiences:0"] = "word-addin-officejs",
["Authentication:ValidAudiences:1"] = "word-versioning-web",
["Cors:AllowedOrigins:0"] = "https://localhost:3000",
// open-link (v9.2): BaseUrl เป็น dummy — test เอา PathAndQuery ของ URL ไป GET ผ่าน host ของ factory
["OpenLinks:Secret"] = "test-open-link-secret",
["OpenLinks:BaseUrl"] = "https://test-host",
}));
});

View file

@ -33,14 +33,14 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
using var browser = new HttpClient();
using var signedDownload = await browser.GetAsync(link!.Url);
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน
using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
using var signedDocx = await browser.GetAsync(docxLink!.Url);
using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
@ -114,7 +114,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
var user = new TestUser();
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
new WordVersioning.Api.Services.HashService(), null!,
new WordVersioning.Api.Services.FieldSyncService(db, user), user,
new WordVersioning.Api.Services.FieldSyncService(db, user), null!, user,
Microsoft.Extensions.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()),
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
var id = Guid.NewGuid();
@ -165,7 +165,6 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
return Task.CompletedTask;
}
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException();
public Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
}