From 8bf6f2004842e683c0147c6276c5f614fae175e5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 16 Sep 2026 18:23:27 +0100 Subject: [PATCH] fixes the upload --- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 133 ++++++++++++++++++ .../Fixtures/TestApiTransport.cs | 93 ++++++++++++ .../Yavsc.Blogs.Tests.csproj | 3 +- src/Yavsc.Server/Helpers/FileSystemHelpers.cs | 21 +-- 4 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index 9d4f6ad81..c57c31c3f 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -4,11 +4,13 @@ using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using Yavsc.Api.Client; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Server.Helpers; using Yavsc.Tests.Shared; using Yavsc.Blogs.Tests.Fixtures; +using BlogPostDto = Yavsc.Blogspot.BlogPostDto; namespace Yavsc.Blogs.Tests; @@ -450,6 +452,137 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode); } + [Fact] + public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment() + { + // Full PostIt "Save" flow for an existing post with a new + // attachment, driven through the real BlogApiClient — the + // same code PostIt runs — so any drift between the + // client's wire shape and the controller's contract fails + // here before it reaches a user: + // + // 1. BlogsViewModel.SaveAsync (update branch) calls + // BlogApiClient.UpdatePostAsync(id, update, attachments) + // with a non-empty attachments list → + // multipart/form-data PUT built by + // CreateMultipartContent: a "blog" field holding the + // BlogPostDto serialised camelCase + WhenWritingNull, + // plus one "file" part per attachment. + // 2. TryAppendAttachmentLinks then appends a markdown + // link per attachment to the article, and the VM + // issues a second UpdatePostAsync without files → + // plain JSON PUT. + // + // The test asserts the final state: the article carries + // the markdown link, the attachment row is persisted, and + // the file physically landed under the user-files root + // the link points to. + ResetAndSeedDefaultUser(); + using var http = NewClient(subject: "tester"); + + var previousRoot = AbstractFileSystemHelpers.UserFilesDirName; + var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + AbstractFileSystemHelpers.UserFilesDirName = tempRoot; + + try + { + // BlogApiClient takes the versioned base address + // (…/api/v1/) and prepends its "blogspot" prefix — + // exactly how PostIt wires it against production. + var client = new BlogApiClient( + new TestApiTransport(http), + _fixture.ApiUrl("")); + + // Seed the post through the same path PostIt uses for + // a first Save on a new draft: CreatePostAsync without + // files → plain JSON POST. + var draft = new BlogPostDto + { + Title = "Avant PostIt", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + var created = await client.CreatePostAsync(draft, + ct: TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.NotEqual(0, created!.Id); + Assert.Equal("tester", created.AuthorId); + + // Step 1 — the update DTO the VM builds from the + // editor buffer (SelectedPost is the read-only hint + // for Id, AuthorId, Photo and DateCreated), sent with + // one attachment → multipart PUT. + var update = new BlogPostDto + { + Id = created.Id, + AuthorId = created.AuthorId, + Photo = created.Photo, + Title = "Modifié depuis PostIt", + Article = "Contenu modifié.", + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }; + var attachments = new[] + { + new BlogUploadFile( + "note.txt", + System.Text.Encoding.UTF8.GetBytes("payload test"), + "text/plain") + }; + + await client.UpdatePostAsync(created.Id, update, attachments, + TestContext.Current.CancellationToken); + + // Step 2 — TryAppendAttachmentLinks appended a + // markdown link to the article (the payload does not + // embed Author, so the owner segment falls back to + // AuthorId), and the VM PUTs the article again, + // without files → JSON path of UpdatePostAsync. + var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt"; + var linkUpdate = new BlogPostDto + { + Id = created.Id, + AuthorId = created.AuthorId, + Photo = created.Photo, + Title = update.Title, + Article = update.Article + $"\n\n- [note.txt]({fileUrl})", + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }; + + await client.UpdatePostAsync(created.Id, linkUpdate, + ct: TestContext.Current.CancellationToken); + + // Final state, read back through the client. + var details = await client.GetPostAsync(created.Id, + TestContext.Current.CancellationToken); + Assert.NotNull(details); + Assert.Equal("Modifié depuis PostIt", details!.Title); + Assert.Contains($"- [note.txt]({fileUrl})", details.Article); + + // The attachment row is persisted… + Assert.True(CountAttachmentsForPost(created.Id) >= 1); + + // …and the file physically landed where the appended + // markdown link points: + // {UserFilesDirName}/{user}/blogs/{postId}/{fileName}. + var expectedFile = Path.Combine( + tempRoot, "tester", "blogs", created.Id.ToString(), "note.txt"); + var written = Directory.Exists(tempRoot) + ? string.Join(", ", Directory.EnumerateFiles(tempRoot, "*", SearchOption.AllDirectories)) + : ""; + Assert.True(File.Exists(expectedFile), + $"Expected uploaded file at {expectedFile}. Actually written: {written}"); + } + finally + { + AbstractFileSystemHelpers.UserFilesDirName = previousRoot; + try { Directory.Delete(tempRoot, recursive: true); } catch { } + } + } + [Fact] public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() { diff --git a/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs b/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs new file mode 100644 index 000000000..9cbc8f1ba --- /dev/null +++ b/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs @@ -0,0 +1,93 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Yavsc.Api.Client; + +namespace Yavsc.Blogs.Tests.Fixtures; + +/// +/// Minimal transport for integration +/// tests: it forwards calls over a +/// test-owned (already authenticated by +/// the caller) and applies the contract documented on the +/// interface — JSON bodies serialised with web defaults +/// (camelCase, case-insensitive reads), and an +/// carrying the response body +/// on non-2xx statuses so assertion failures stay actionable. +/// +/// Unlike PostIt's YavscApiClient, there is no +/// silent-refresh-on-401: the test token never expires mid-test, +/// and a 401 is a failure we want surfaced, not retried. +/// +/// The is owned by the test, not by this +/// transport, so is a no-op. +/// +public sealed class TestApiTransport : IYavscApiClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public TestApiTransport(HttpClient http) + { + Http = http ?? throw new ArgumentNullException(nameof(http)); + } + + public HttpClient Http { get; } + + public Task CallAsync( + HttpMethod method, + string path, + object? body = null, + CancellationToken ct = default) + => SendAsync(method, path, + body is null ? null : () => JsonContent.Create(body, options: JsonOptions), + ct); + + public Task CallAsync( + HttpMethod method, + string path, + Func contentFactory, + CancellationToken ct = default) + => SendAsync(method, path, contentFactory, ct); + + public async Task CallAsync( + HttpMethod method, + string path, + object? body = null, + CancellationToken ct = default) + => await SendAsync(method, path, + body is null ? null : () => JsonContent.Create(body, options: JsonOptions), + ct); + + public async Task CallAsync( + HttpMethod method, + string path, + Func contentFactory, + CancellationToken ct = default) + => await SendAsync(method, path, contentFactory, ct); + + private async Task SendAsync( + HttpMethod method, + string path, + Func? contentFactory, + CancellationToken ct) + { + using var request = new HttpRequestMessage(method, path); + if (contentFactory is not null) + request.Content = contentFactory(); + + using var response = await Http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException( + $"HTTP {(int)response.StatusCode} {response.StatusCode} on {method} {path}. Body: {error}"); + } + + var payload = await response.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(payload)) + return default!; // 201/204 with an empty body (PUT returns 204). + + return JsonSerializer.Deserialize(payload, JsonOptions)!; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 887648060..1972b283a 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -25,6 +25,7 @@ + @@ -32,4 +33,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs index 29ada6b77..4c83d99ca 100644 --- a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs +++ b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs @@ -153,22 +153,23 @@ namespace Yavsc.Server.Helpers return new FsOperationInfo { Done = true }; } - static string ParseFileNameFromDisposition(string disposition) - { - // form-data_ name=_file__ filename=_Constants.Private.cs_ - var parts = disposition.Split(' '); - var filename = parts[2].Split('=')[1]; - filename = filename.Substring(1, filename.Length - 2); - return filename; - } - public static void AddQuota(this ApplicationUser user, int quota) { user.DiskQuota += quota; } public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, IFormFile f, string destFileName = null) { - return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? ParseFileNameFromDisposition(f.ContentDisposition), f.ContentType, CancellationToken.None); + // f.FileName is parsed by ASP.NET Core from the + // Content-Disposition header: it handles unquoted + // values and the RFC 5987 filename* parameter that + // modern HttpClient multipart bodies emit. The + // previous hand-rolled parser assumed a quoted + // filename in a fixed position and silently dropped + // the first character ("note.txt" → "ote.txt"), + // which broke the attachment links PostIt appends + // to the article. FilterFileName, downstream, still + // sanitises any path traversal attempt. + return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? f.FileName, f.ContentType, CancellationToken.None); } ///