diff --git a/Models/Dtos/Editor/EditorAreaRequestParser.cs b/Models/Dtos/Editor/EditorAreaRequestParser.cs index f152f1c2..293ccbca 100644 --- a/Models/Dtos/Editor/EditorAreaRequestParser.cs +++ b/Models/Dtos/Editor/EditorAreaRequestParser.cs @@ -11,10 +11,6 @@ internal static class EditorAreaRequestParser { private static readonly string[] SaveServerOwnedFields = { "id", "tripId", "displayOrder", "capabilities" }; private static readonly Regex FillHexRegex = new("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled); - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft area create request. /// @@ -46,7 +42,7 @@ public static bool TryParseCreate(JsonElement request, out EditorAreaSaveRequest update = new EditorAreaSaveRequest( string.IsNullOrWhiteSpace(name) ? "Area" : name!.Trim(), - notesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), string.IsNullOrWhiteSpace(fillHex) ? "#ff6600" : fillHex!.Trim().ToLowerInvariant(), geometry!); return true; @@ -78,7 +74,7 @@ public static bool TryParseUpdate(JsonElement request, out EditorAreaSaveRequest return false; } - update = new EditorAreaSaveRequest(name!.Trim(), notesHtml, fillHex!.Trim().ToLowerInvariant(), geometry!); + update = new EditorAreaSaveRequest(name!.Trim(), EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), fillHex!.Trim().ToLowerInvariant(), geometry!); return true; } @@ -350,7 +346,7 @@ private static void ValidateFillHex(string? fillHex, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes cannot contain data image sources." }; } diff --git a/Models/Dtos/Editor/EditorPlaceRequestParser.cs b/Models/Dtos/Editor/EditorPlaceRequestParser.cs index 9146c08b..baacc3a8 100644 --- a/Models/Dtos/Editor/EditorPlaceRequestParser.cs +++ b/Models/Dtos/Editor/EditorPlaceRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; namespace Wayfarer.Models.Dtos.Editor; @@ -17,10 +16,6 @@ internal static class EditorPlaceRequestParser "capabilities" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft place create request. /// @@ -51,7 +46,7 @@ public static bool TryParseCreate( update = new EditorPlaceCreateRequest( fields.Name!, - fields.NotesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(fields.NotesHtml), fields.Address, fields.Location, fields.IconName!, @@ -90,7 +85,7 @@ public static bool TryParseUpdate( update = new EditorPlaceUpdateRequest( regionId!.Value, fields.Name!, - fields.NotesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(fields.NotesHtml), fields.Address, fields.Location, fields.IconName!, @@ -337,7 +332,7 @@ private static void RejectPathOwnedField(JsonElement request, string field, Dict } private static bool ContainsDataImageSource(string? notesHtml) => - !string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml); + EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml); private sealed record PlaceSaveFields( string? Name, diff --git a/Models/Dtos/Editor/EditorRegionRequestParser.cs b/Models/Dtos/Editor/EditorRegionRequestParser.cs index b30d5361..f24d2ed8 100644 --- a/Models/Dtos/Editor/EditorRegionRequestParser.cs +++ b/Models/Dtos/Editor/EditorRegionRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; namespace Wayfarer.Models.Dtos.Editor; @@ -20,10 +19,6 @@ internal static class EditorRegionRequestParser "capabilities" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft region save request. /// @@ -57,7 +52,7 @@ public static bool TryParseSave( return false; } - update = new EditorRegionSaveRequest(name!, notesHtml, coverImage, center); + update = new EditorRegionSaveRequest(name!, EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), coverImage, center); return true; } @@ -276,7 +271,7 @@ private static void ValidateCoverImage(string? rawUrl, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes images must use external image URLs, not data:image sources." }; } diff --git a/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs new file mode 100644 index 00000000..cdf8d86e --- /dev/null +++ b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Text.RegularExpressions; +using AngleSharp.Dom; +using AngleSharp.Html.Parser; +using Microsoft.AspNetCore.WebUtilities; + +namespace Wayfarer.Models.Dtos.Editor; + +/// +/// Normalizes Trip Editor rich-notes request HTML before editor mutations persist it. +/// +internal static class EditorRichNotesRequestHtml +{ + private static readonly Regex DataImageSourceRegex = new( + @"]*?\bsrc\s*=\s*[""']?\s*data:image/", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex ImageSourceRegex = new( + @"(?]*?\bsrc\s*=\s*[""'])(?[^""']+)(?[""'])", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly HtmlParser Parser = new(); + private static readonly HashSet AllowedTags = new(StringComparer.OrdinalIgnoreCase) + { + "a", "blockquote", "br", "em", "h1", "h2", "h3", "h4", "h5", "h6", "img", "li", "ol", "p", "span", "strong", "u", "ul" + }; + + private static readonly HashSet RemovedTags = new(StringComparer.OrdinalIgnoreCase) + { + "base", "button", "embed", "form", "iframe", "input", "link", "meta", "object", "option", "script", "select", "style", "textarea" + }; + + private static readonly HashSet AllowedAlignmentClasses = new(StringComparer.Ordinal) + { + "ql-align-center", "ql-align-right" + }; + + private static readonly HashSet AllowedFontClasses = new(StringComparer.Ordinal) + { + "ql-font-monospace", "ql-font-serif" + }; + + private static readonly HashSet QuillBlockTags = new(StringComparer.OrdinalIgnoreCase) + { + "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p" + }; + + private static readonly HashSet AllowedListKinds = new(StringComparer.Ordinal) + { + "bullet", "ordered" + }; + + /// + /// Returns true when request HTML contains a direct embedded data image source. + /// + public static bool ContainsDataImageSource(string? notesHtml) => + !string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml); + + /// + /// Canonicalizes and sanitizes rich-notes HTML accepted by Trip Editor mutation requests. + /// + public static string? NormalizeForPersistence(string? notesHtml) + { + if (string.IsNullOrWhiteSpace(notesHtml)) + { + return string.Empty; + } + + var canonicalImages = ImageSourceRegex.Replace(notesHtml.Trim(), match => + { + var source = CanonicalImageSource(match.Groups["url"].Value); + return $"{match.Groups["prefix"].Value}{WebUtility.HtmlEncode(source)}{match.Groups["suffix"].Value}"; + }); + + var document = Parser.ParseDocument(canonicalImages); + var body = document.Body; + if (body == null) + { + return string.Empty; + } + + foreach (var element in body.QuerySelectorAll("span.ql-ui").ToArray()) + { + element.Remove(); + } + + foreach (var element in body.QuerySelectorAll("*").Reverse().ToArray()) + { + NormalizeElement(element); + } + + RemoveTrailingBlankParagraphs(body); + var html = body.InnerHtml.Trim(); + return string.Equals(html, "


", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : html; + } + + private static void NormalizeElement(IElement element) + { + if (RemovedTags.Contains(element.TagName)) + { + element.Remove(); + return; + } + + if (!AllowedTags.Contains(element.TagName)) + { + element.Replace(element.ChildNodes.ToArray()); + return; + } + + foreach (var attribute in element.Attributes.ToArray()) + { + if (!IsAllowedAttribute(element, attribute)) + { + element.RemoveAttribute(attribute.Name); + } + } + + if (string.Equals(element.TagName, "img", StringComparison.OrdinalIgnoreCase)) + { + NormalizeImage(element); + } + } + + private static bool IsAllowedAttribute(IElement element, IAttr attribute) + { + var name = attribute.Name.ToLowerInvariant(); + if (name == "class") + { + return NormalizeClassAttribute(element); + } + + if (name == "href" && string.Equals(element.TagName, "a", StringComparison.OrdinalIgnoreCase)) + { + return IsAllowedLink(attribute.Value); + } + + if (name == "src" && string.Equals(element.TagName, "img", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name == "data-list" + && string.Equals(element.TagName, "li", StringComparison.OrdinalIgnoreCase) + && AllowedListKinds.Contains(attribute.Value); + } + + private static bool NormalizeClassAttribute(IElement element) + { + var allowed = element.ClassList.Where(className => IsAllowedClass(element, className)).ToArray(); + if (allowed.Length == 0) + { + return false; + } + + element.SetAttribute("class", string.Join(" ", allowed)); + return true; + } + + private static bool IsAllowedClass(IElement element, string className) => + (string.Equals(element.TagName, "span", StringComparison.OrdinalIgnoreCase) && AllowedFontClasses.Contains(className)) + || (QuillBlockTags.Contains(element.TagName) && AllowedAlignmentClasses.Contains(className)); + + private static void NormalizeImage(IElement element) + { + var source = CanonicalImageSource(element.GetAttribute("src") ?? string.Empty); + if (!IsAllowedAbsoluteHttpUrl(source)) + { + element.Remove(); + return; + } + + element.SetAttribute("src", source); + } + + private static bool IsAllowedLink(string value) + { + var compact = CompactUrlScheme(value); + return !compact.StartsWith("javascript:", StringComparison.Ordinal) + && !compact.StartsWith("data:", StringComparison.Ordinal) + && !compact.StartsWith("vbscript:", StringComparison.Ordinal); + } + + private static bool IsAllowedAbsoluteHttpUrl(string value) => + Uri.TryCreate(StripUrlBoundaryControls(value), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + private static void RemoveTrailingBlankParagraphs(IElement body) + { + while (body.LastElementChild != null + && string.Equals(body.LastElementChild.TagName, "p", StringComparison.OrdinalIgnoreCase) + && IsBlankParagraph(body.LastElementChild)) + { + body.LastElementChild.Remove(); + } + } + + private static bool IsBlankParagraph(IElement element) + { + var text = (element.TextContent ?? string.Empty).Replace('\u00a0', ' '); + return string.IsNullOrWhiteSpace(text) + && element.QuerySelector("img") == null + && element.QuerySelector("video") == null + && element.QuerySelector("iframe") == null; + } + + private static string CanonicalImageSource(string value) + { + var trimmed = StripUrlBoundaryControls(WebUtility.HtmlDecode(value)); + if (!Uri.TryCreate(trimmed, UriKind.RelativeOrAbsolute, out var uri) + || !string.Equals(uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString.Split('?')[0], "/Public/ProxyImage", StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + var query = uri.IsAbsoluteUri ? uri.Query : new Uri(new Uri("https://wayfarer.local"), uri).Query; + var values = QueryHelpers.ParseQuery(query); + return values.TryGetValue("url", out var target) && target.Count > 0 + ? StripUrlBoundaryControls(target[0] ?? trimmed) + : trimmed; + } + + private static string StripUrlBoundaryControls(string value) => + Regex.Replace(value, @"^[\u0000-\u0020\u007f-\u009f]+|[\u0000-\u0020\u007f-\u009f]+$", string.Empty); + + private static string CompactUrlScheme(string value) => + Regex.Replace(StripUrlBoundaryControls(value)[..Math.Min(64, StripUrlBoundaryControls(value).Length)], @"[\u0000-\u0020\u007f-\u009f]+", string.Empty).ToLowerInvariant(); +} diff --git a/Models/Dtos/Editor/EditorSegmentRequestParser.cs b/Models/Dtos/Editor/EditorSegmentRequestParser.cs index cfdd9279..93038a77 100644 --- a/Models/Dtos/Editor/EditorSegmentRequestParser.cs +++ b/Models/Dtos/Editor/EditorSegmentRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; using NetTopologySuite.Geometries; using Wayfarer.Services; @@ -11,10 +10,6 @@ namespace Wayfarer.Models.Dtos.Editor; internal static class EditorSegmentRequestParser { private static readonly string[] ServerOwnedFields = { "id", "tripId", "displayOrder", "capabilities" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft segment save request. /// @@ -49,7 +44,7 @@ public static bool TryParseSave(JsonElement request, out EditorSegmentSaveReques CanonicalMode(mode), distance, duration, - notesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), route); return true; } @@ -288,7 +283,7 @@ private static string CanonicalMode(string? mode) => private static void ValidateNotes(string? notesHtml, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes cannot contain data image sources." }; } diff --git a/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs b/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs index dfb6732a..1324d8e7 100644 --- a/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs +++ b/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; namespace Wayfarer.Models.Dtos.Editor; @@ -16,10 +15,6 @@ internal static class EditorTripMetadataUpdateRequestParser "updatedAt" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse an editor metadata update request and returns field-keyed validation errors. /// @@ -55,7 +50,7 @@ public static bool TryParse( return false; } - update = new EditorTripMetadataUpdateRequest(name!, notesHtml, isPublic!.Value, coverImage, center, zoom); + update = new EditorTripMetadataUpdateRequest(name!, EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), isPublic!.Value, coverImage, center, zoom); return true; } @@ -267,7 +262,7 @@ private static void ValidateCoverImage(string? rawUrl, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes images must use external image URLs, not data:image sources." }; } diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs index 6469f306..7464858d 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs @@ -78,6 +78,29 @@ public async Task PatchMetadataNullNotesStoresEmptyString() Assert.Equal(string.Empty, db.Trips.Single(t => t.Id == trip.Id).Notes); } + [Fact] + public async Task PatchMetadataNormalizesRichNotesBeforePersisting() + { + using var db = CreateDbContext(); + var trip = SeedTrip(db, "owner-user"); + var controller = BuildController(db); + ConfigureControllerWithUserRole(controller, "owner-user"); + const string expectedNotes = "

Centered

"; + + var result = await PatchMetadata(controller, trip.Id, ValidMetadataJson(notesHtml: """ + "

Centered


" + """), CancellationToken.None); + + var envelope = AssertMutation(result); + var stored = db.Trips.Single(t => t.Id == trip.Id); + Assert.Equal(expectedNotes, envelope.Data.NotesHtml); + Assert.Equal(expectedNotes, stored.Notes); + Assert.DoesNotContain("/Public/ProxyImage", stored.Notes); + Assert.DoesNotContain("onclick", stored.Notes); + Assert.DoesNotContain("onerror", stored.Notes); + Assert.DoesNotContain("


", stored.Notes); + } + [Theory] [InlineData("null")] [InlineData("""{ "rawUrl": null }""")] diff --git a/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs new file mode 100644 index 00000000..9562eb7b --- /dev/null +++ b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using Wayfarer.Models.Dtos.Editor; +using Xunit; + +namespace Wayfarer.Tests.Models; + +/// +/// Contract tests for Trip Editor rich-notes request HTML before persistence. +/// +public sealed class EditorRichNotesRequestHtmlTests +{ + [Fact] + public void NormalizeForPersistence_PreservesAllowedFormattingListsAlignmentLinksAndImages() + { + var input = string.Concat( + "

Bold Italic Underline

", + "
  1. Bullet
  2. ", + "
  3. Ordered
", + "

Left

", + "

Center

", + "

Right

", + "

Serif

", + "

Link

", + "

"); + + var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input); + + Assert.Contains("Bold", result); + Assert.Contains("Italic", result); + Assert.Contains("Underline", result); + Assert.Contains("data-list=\"bullet\"", result); + Assert.Contains("data-list=\"ordered\"", result); + Assert.Contains("

Left

", result); + Assert.Contains("

Center

", result); + Assert.Contains("

Right

", result); + Assert.Contains("Serif", result); + Assert.Contains("href=\"https://example.test/page\"", result); + Assert.Contains("src=\"https://cdn.example.test/image.jpg\"", result); + Assert.DoesNotContain("ql-align-left", result); + Assert.DoesNotContain("ql-ui", result); + Assert.DoesNotContain("onclick", result); + Assert.DoesNotContain("onerror", result); + } + + [Fact] + public void NormalizeForPersistence_StripsQuillClassesFromUnsupportedElements() + { + var result = EditorRichNotesRequestHtml.NormalizeForPersistence( + "

Inline alignment

Block font

"); + + Assert.Contains("Inline alignment", result); + Assert.Contains("

Block font

", result); + Assert.DoesNotContain("ql-align-right", result); + Assert.DoesNotContain("ql-font-serif", result); + } + + [Fact] + public void NormalizeForPersistence_UnwrapsProxyImageUrlsBeforeSave() + { + var result = EditorRichNotesRequestHtml.NormalizeForPersistence( + "

"); + + Assert.Contains("src=\"https://cdn.example.test/proxied.jpg\"", result); + Assert.DoesNotContain("/Public/ProxyImage", result); + } + + [Fact] + public void NormalizeForPersistence_StripsDataImagesUnsafeUrlsAndScripts() + { + var input = string.Concat( + "", + "

Safe text

", + "

Unsafe link

", + "

", + "

"); + + var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input); + + Assert.Contains("Safe text", result); + Assert.Contains("Unsafe link", result); + Assert.DoesNotContain("

", "")] + [InlineData("

Real content


", "

Real content

")] + [InlineData("


", "

")] + public void NormalizeForPersistence_RemovesOnlyTrailingHelperBlankParagraphs(string input, string expected) + { + var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input); + + Assert.Equal(expected, result); + } + + [Fact] + public void ContainsDataImageSource_DetectsDirectDataImageBeforeNormalization() + { + Assert.True(EditorRichNotesRequestHtml.ContainsDataImageSource("

")); + Assert.False(EditorRichNotesRequestHtml.ContainsDataImageSource("

")); + } + + [Fact] + public void SaveRequestParsersNormalizeRichNotesBeforeMutationServicesPersist() + { + const string notesHtml = "

Right


"; + const string expectedNotesHtml = "

Right

"; + + Assert.True(EditorTripMetadataUpdateRequestParser.TryParse(Json($$""" + { + "name": "Trip", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "isPublic": false, + "coverImage": null, + "center": null, + "zoom": null + } + """), out var metadata, out _)); + Assert.Equal(expectedNotesHtml, metadata.NotesHtml); + + Assert.True(EditorRegionRequestParser.TryParseSave(Json($$""" + { + "name": "Region", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "coverImage": null, + "center": null + } + """), out var region, out _)); + Assert.Equal(expectedNotesHtml, region.NotesHtml); + + Assert.True(EditorPlaceRequestParser.TryParseCreate(Json($$""" + { + "name": "Place", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "address": null, + "location": null, + "iconName": "marker", + "markerColor": "bg-blue", + "reverseGeocode": false + } + """), new HashSet { "marker" }, new HashSet { "bg-blue" }, out var place, out _)); + Assert.Equal(expectedNotesHtml, place.NotesHtml); + + Assert.True(EditorAreaRequestParser.TryParseCreate(Json($$""" + { + "name": "Area", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "fillHex": "#ff6600", + "geometry": { "type": "Polygon", "coordinates": [[[23,37],[24,37],[24,38],[23,37]]] } + } + """), out var area, out _)); + Assert.Equal(expectedNotesHtml, area.NotesHtml); + + Assert.True(EditorSegmentRequestParser.TryParseSave(Json($$""" + { + "fromPlaceId": null, + "toPlaceId": null, + "mode": "walk", + "estimatedDistanceKm": null, + "estimatedDurationMinutes": null, + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "route": null + } + """), out var segment, out _)); + Assert.Equal(expectedNotesHtml, segment.NotesHtml); + } + + private static JsonElement Json(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + private static string JsonEncodedText(string value) => System.Text.Json.JsonEncodedText.Encode(value).ToString(); +} diff --git a/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts b/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts index ef3c757b..ec1b006d 100644 --- a/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts +++ b/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts @@ -61,6 +61,7 @@ test.describe.serial('Trip Editor rich notes parity', () => { await page.keyboard.press('Enter'); await metadataEditor.locator('button.ql-align[value="right"]').click(); await page.keyboard.type('Right aligned note'); + await routeRichNoteImages(page, 'https://example.com/photo.jpg'); await insertImageUrl(metadataForm, 'https://example.com/photo.jpg'); await page.getByRole('button', { name: 'Save & Continue' }).click(); await expect.poll(() => requests.length).toBe(1); @@ -144,6 +145,7 @@ test.describe.serial('Trip Editor rich notes parity', () => { await loadWorkspaceWithRichNotesFixture(page); const form = page.locator('#trip-editor-metadata-form'); + await routeRichNoteImages(page, 'https://images.example.test/rich-note.png'); await insertImageUrl(form, 'https://images.example.test/rich-note.png'); const image = richEditor(form).locator('.ql-editor img'); await expect(image).toHaveAttribute('src', /\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Frich-note\.png$/); @@ -393,14 +395,10 @@ function terminalImageNotes(): string { return '

Intro before image

'; } -async function routeRichNoteImages(page: Page): Promise { - await page.route(/\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Fterminal-large\.svg$/i, async route => { - await route.fulfill({ - status: 200, - contentType: 'image/svg+xml', - body: 'Terminal rich note image' - }); - }); +async function routeRichNoteImages(page: Page, ...urls: string[]): Promise { + for (const url of ['https://images.example.test/terminal-large.svg', ...urls]) { + await page.route(new RegExp(`/Public/ProxyImage\\?url=${encodeURIComponent(url).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'), route => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' })); + } } async function routeEditorMutations(page: Page, state: MutableEditorState, requests: Array<{ method: string; url: string; body: Record }>): Promise { diff --git a/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts b/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts new file mode 100644 index 00000000..89ea55cd --- /dev/null +++ b/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts @@ -0,0 +1,119 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { + absoluteUrl, + editorApiPath, + editorPath, + expectMountedWorkspace, + loadEditorStateFixture, + signIn, + uniqueName +} from './tripEditorTestUtils'; + +type EditorState = Record; + +test.describe.serial('Trip Editor rich notes real persistence contract', () => { + test('metadata rich notes save through the real endpoint and reload as canonical HTML', async ({ page }) => { + await signIn(page); + await page.goto(absoluteUrl(editorPath)); + await expectMountedWorkspace(page); + const originalState = await loadEditorStateFixture(page) as EditorState; + const originalMetadata = { ...originalState.metadata }; + const imageUrl = 'https://images.example.test/rich-notes-persistence.png'; + const runText = uniqueName('PW rich notes persisted'); + await routeProxyImage(page, imageUrl); + + try { + const form = page.locator('#trip-editor-metadata-form'); + const editor = richEditor(form).locator('.ql-editor'); + await editor.click(); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(runText); + await page.keyboard.press('Enter'); + await richEditor(form).locator('button.ql-align[value="center"]').click(); + await page.keyboard.type('Centered persisted note'); + await insertImageUrl(form, imageUrl); + await expect(editor).toContainText(runText); + await expect(editor.locator('p.ql-align-center')).toContainText('Centered persisted note'); + + const saveResponse = page.waitForResponse(response => + response.url().endsWith(`${editorApiPath}/metadata`) && response.request().method() === 'PATCH'); + await page.getByRole('button', { name: 'Save & Continue' }).click(); + expect((await saveResponse).ok()).toBeTruthy(); + await expectSaved(page); + + await expectState(page, state => { + const notes = state.metadata.notesHtml as string; + expect(notes).toContain(runText); + expect(notes).toContain('

Centered persisted note'); + expect(notes).toContain(`src="${imageUrl}"`); + expect(notes).not.toContain('/Public/ProxyImage'); + expect(notes).not.toContain('


'); + }); + + await page.reload(); + await expectMountedWorkspace(page); + const reloadedForm = page.locator('#trip-editor-metadata-form'); + const reloadedEditor = richEditor(reloadedForm).locator('.ql-editor'); + await expect(reloadedEditor).toContainText(runText); + await expect(reloadedEditor.locator('p.ql-align-center').filter({ hasText: 'Centered persisted note' })).toBeVisible(); + await expect(reloadedEditor.locator('img')).toHaveAttribute('src', /\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Frich-notes-persistence\.png$/); + } finally { + if (!page.isClosed()) { + await restoreMetadata(page, originalMetadata); + } + } + }); +}); + +async function routeProxyImage(page: Page, url: string): Promise { + const escaped = encodeURIComponent(url).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page.route(new RegExp(`/Public/ProxyImage\\?url=${escaped}$`, 'i'), async route => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: 'Rich notes persistence' + }); + }); +} + +function richEditor(form: Locator): Locator { + return form.locator('.trip-editor-rich-notes'); +} + +async function insertImageUrl(form: Locator, url: string): Promise { + await richEditor(form).locator('.ql-image').click(); + const dialog = form.page().getByRole('dialog', { name: 'Insert image URL' }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Image URL').fill(url); + await dialog.getByRole('button', { name: 'Insert Image' }).click(); + await expect(dialog).toHaveCount(0); +} + +async function expectSaved(page: Page): Promise { + await expect(page.locator('.trip-editor-save-state').filter({ hasText: /saved/i }).first()).toBeVisible(); +} + +async function expectState(page: Page, assertion: (state: EditorState) => void): Promise { + assertion(await loadEditorStateFixture(page) as EditorState); +} + +async function restoreMetadata(page: Page, metadata: Record): Promise { + const token = await page.locator('#trip-editor-antiforgery input[name="__RequestVerificationToken"]').inputValue().catch(() => ''); + if (!token) { + return; + } + + const response = await page.request.patch(absoluteUrl(`${editorApiPath}/metadata`), { + data: { + name: metadata.name, + notesHtml: metadata.notesHtml, + isPublic: metadata.isPublic, + coverImage: metadata.coverImage, + center: metadata.center, + zoom: metadata.zoom + }, + headers: { RequestVerificationToken: token } + }); + expect(response.ok(), `metadata cleanup PATCH returned ${response.status()}: ${await response.text()}`).toBeTruthy(); +}