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>
174 lines
10 KiB
C#
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);
|
|
}
|
|
|
|
}
|