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:
parent
e0558e3cf4
commit
ee4d8573ee
21 changed files with 340 additions and 145 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue