eCourt-Doc-Edit/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs
Suphonchai Phoonsawat f4b2ff9d28 v9.2.1: real filenames back in open-link URLs (was literal "file")
User report: downloaded/opened files named just "file" — v9.2 put a
literal file.docx/file.pdf at the end of open-link URLs (treated as
cosmetic since Content-Disposition carries the real name, but Word
names its URL-copy from the URL path and browsers fall back to the
URL filename)

- OpenLinkService.CreateLink takes fileName and appends it (URL-encoded,
  Thai-safe) — version: {base}_v{N}.docx/.pdf per FR-15, draft:
  {template}_{timestamp}.docx per FR-10 naming policy
- Draft route split into /d/{templateId}/{stamp}/{fileName} (rebuilds
  objectKey + HMAC-validates as before); version route unchanged shape
  with real name
- Tests: URL tails assert real names (คำร้อง_v1.pdf/_v1.docx, draft
  template name), no /file.docx; new tamper tests — modified stamp/
  templateId and swapped docx↔pdf extension (version route) → 404
- backend 89/89; QA reviewer PASS (1 round, minors 2 fixed)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:18:36 +07:00

174 lines
10 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
[Collection(IntegrationTestCollection.Name)]
public sealed class VersionPdfTests(IntegrationTestFixture fixture)
{
private static readonly byte[] Pdf = "%PDF-1.7\nversion snapshot\n%%EOF"u8.ToArray();
[Fact]
public async Task Upload_WithPdf_PersistsSiblingKey_AndDownloadsExactBytes()
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
var clientId = Guid.NewGuid().ToString();
using var response = await client.PostAsync($"api/documents/{id}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", clientId, pdf: Pdf));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.True(result.HasPdf);
Assert.Equal(1, result.VersionNumber);
await using var db = fixture.CreateDb();
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == result.VersionId);
Assert.Equal($"{id}/v0001_{clientId}.pdf", row.PdfObjectKey);
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{id}/versions");
Assert.True(history!.Single().HasPdf);
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/download?format=PDF");
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
Assert.Equal(Pdf, await download.Content.ReadAsByteArrayAsync());
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");
// ชื่อไฟล์จริง ({base}_v{N}.pdf) อยู่ท้าย URL ด้วย (Word/browser ตั้งชื่อตาม URL ไม่ใช่แค่ disposition)
Assert.EndsWith("คำร้อง_v1.pdf", Uri.UnescapeDataString(new Uri(link!.Url).AbsolutePath.Split('/')[^1]));
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");
Assert.EndsWith("คำร้อง_v1.docx", Uri.UnescapeDataString(new Uri(docxLink!.Url).AbsolutePath.Split('/')[^1]));
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);
}
[Fact]
public async Task Upload_WithoutPdf_HasFalse_AndBothPdfDownloadsReturn404()
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
using var response = await client.PostAsync($"api/documents/{id}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString()));
response.EnsureSuccessStatusCode();
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.False(result.HasPdf);
await using var db = fixture.CreateDb();
Assert.Null((await db.DocumentVersions.SingleAsync(v => v.Id == result.VersionId)).PdfObjectKey);
foreach (var endpoint in new[] { "download", "download-link" })
{
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/{endpoint}?format=pdf");
Assert.Equal(HttpStatusCode.NotFound, download.StatusCode);
}
}
[Theory]
[InlineData("bad.txt", "application/pdf", false)]
[InlineData("doc.pdf", "text/plain", false)]
[InlineData("doc.pdf", "application/pdf", true)]
public async Task InvalidPdf_Returns400_BeforeCreatingDocument(string name, string type, bool empty)
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
using var form = ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString());
var pdf = new ByteArrayContent(empty ? [] : Pdf);
pdf.Headers.ContentType = new MediaTypeHeaderValue(type);
form.Add(pdf, "pdf", name);
using var response = await client.PostAsync($"api/documents/{id}/versions", form);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
}
[Fact]
public async Task Generate_V0WithoutPdf_ThenUpload_V1WithPdf()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "pdf-gen");
var generated = await TestFlow.GenerateDocumentAsync(client, templateId);
Assert.Equal(0, generated.VersionNumber);
await using var db = fixture.CreateDb();
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == generated.VersionId);
Assert.Null(row.PdfObjectKey);
Assert.StartsWith($"{generated.DocumentId}/v0000_", row.ObjectKey);
using var response = await client.PostAsync($"api/documents/{generated.DocumentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(), pdf: Pdf));
response.EnsureSuccessStatusCode();
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.Equal(1, result.VersionNumber);
Assert.True(result.HasPdf);
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{generated.DocumentId}/versions");
Assert.Equal(new[] { 1, 0 }, history!.Select(v => v.VersionNumber));
Assert.False(history[1].HasPdf);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task StorageFailure_PdfIsBestEffort_DocxStillRollsBack(bool failDocx)
{
await using var db = fixture.CreateDb();
var storage = new FailingStorage(failDocx);
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), 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();
using var docxStream = new MemoryStream(TestDocx.BuildPlainDocx());
using var pdfStream = new MemoryStream(Pdf);
var request = new WordVersioning.Api.Models.UploadVersionRequest
{
File = new Microsoft.AspNetCore.Http.FormFile(docxStream, 0, docxStream.Length, "file", "doc.docx")
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = TestDocx.DocxContentType },
Pdf = new Microsoft.AspNetCore.Http.FormFile(pdfStream, 0, pdfStream.Length, "pdf", "doc.pdf")
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = "application/pdf" },
ClientVersionId = Guid.NewGuid().ToString(),
};
var response = await controller.UploadVersion(id, request, CancellationToken.None);
if (failDocx)
{
Assert.Equal(502, Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response).StatusCode);
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
Assert.False(await db.DocumentVersions.AnyAsync(v => v.DocumentId == id));
Assert.Single(storage.Keys);
}
else
{
var created = Assert.IsType<Microsoft.AspNetCore.Mvc.CreatedAtActionResult>(response);
Assert.False(Assert.IsType<WordVersioning.Api.Models.UploadVersionResponse>(created.Value).HasPdf);
await using var persisted = fixture.CreateDb();
var row = await persisted.DocumentVersions.SingleAsync(v => v.DocumentId == id);
Assert.Null(row.PdfObjectKey);
Assert.Equal(2, storage.Keys.Count);
Assert.EndsWith(".docx", storage.Keys[0]);
Assert.EndsWith(".pdf", storage.Keys[1]);
}
}
private sealed class TestUser : WordVersioning.Api.Services.ICurrentUser
{
public string UserId => "test";
public string? UserName => "test";
}
private sealed class FailingStorage(bool failDocx) : WordVersioning.Api.Services.IObjectStorageService
{
public List<string> Keys { get; } = [];
public Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
{
Keys.Add(key);
if (failDocx || key.EndsWith(".pdf")) throw new Amazon.Runtime.AmazonServiceException("Simulated storage failure");
return Task.CompletedTask;
}
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
}
}