Files
Umbraco-CMS/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManagerTests.cs
Jacob Overgaard 820c34432a Preview: Fix preview showing published version when Save and Preview is clicked multiple times (closes #20981) (#20992)
* Fix preview showing published version when Save and Preview is clicked multiple times

Fixes #20981

When clicking "Save and Preview" multiple times, the preview tab would show the published version instead of the latest saved version. This occurred because:

1. Each "Save and Preview" creates a new preview session with a new token
2. The preview window is reused (via named window target)
3. Without a URL change, the browser doesn't reload and misses the new session token
4. The stale page gets redirected to the published URL

Solution: Add a cache-busting parameter (?rnd=timestamp) to the preview URL, forcing the browser to reload and pick up the new preview session token. This aligns with how SignalR refreshes work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Improve Save and Preview to avoid full page reloads when preview is already open

When clicking "Save and Preview" multiple times with a preview tab already open, the entire preview tab would reload. This enhancement makes it behave like the "Save" button - only the iframe reloads, not the entire preview wrapper.

Changes:
- Store reference to preview window when opened
- Check if preview window is still open before creating new session
- If open, just focus it and let SignalR handle the iframe refresh
- If closed, create new preview session and open new window

This provides a smoother UX where subsequent saves don't cause the preview frame and controls to reload, only the content iframe refreshes via SignalR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Close preview window when ending preview session

Changes the "End Preview" behavior to close the preview tab instead of navigating to the published URL. This provides a cleaner UX and ensures subsequent "Save and Preview" actions will always create a fresh preview session.

Benefits:
- Eliminates edge case where preview window remains open but is no longer in preview mode
- Simpler behavior - preview session ends and window closes
- Users can use "Preview website" button if they want to view published page

Also removes unnecessary await on SignalR connection.stop() to prevent blocking if the connection cleanup hangs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix preview cookie expiration and add proper error handling

This commit addresses cookie management issues in the preview system:

1. **Cookie Expiration API Enhancement**
   - Added `ExpireCookie` overload with security parameters (httpOnly, secure, sameSiteMode)
   - Added `SetCookieValue` overload with optional expires parameter
   - Marked old methods as obsolete for removal in Umbraco 19
   - Ensures cookies are expired with matching security attributes

2. **PreviewService Cookie Handling**
   - Changed to use new `ExpireCookie` method with explicit security attributes
   - Maintains `Secure=true` and `SameSite=None` for cross-site scenarios
   - Uses new `SetCookieValue` overload with explicit expires parameter
   - Properly expires preview cookies when ending preview session

3. **Frontend Error Handling**
   - Added try-catch around preview window reference checks
   - Handles stale window references gracefully
   - Prevents potential errors from accessing closed window properties

These changes ensure preview cookies are properly managed throughout their
lifecycle and support both same-site and cross-site scenarios (e.g., when
the backoffice is on a different domain/port during development).

Fixes #20981

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Track document ID for preview window to prevent reusing window across different documents

When navigating from one document to another in the backoffice, the preview window reference was being reused even though it was showing a different document. This meant clicking "Save and Preview" would just focus the existing window without updating it to show the new document.

Now we track which document the preview window is showing and only reuse the window if:
1. The window is still open
2. The window is showing the same document

This ensures each document gets its own preview session while still avoiding unnecessary full page reloads when repeatedly previewing the same document.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove updates to ICookieManager and use Cookies.Delete to remove cookie.

* Fix file not found on click to save and preview.

* Removed further currently unnecessary updates to the cookie manager interface and implementation.

* Fixed failing unit test.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-12-01 11:16:35 +00:00

92 lines
2.9 KiB
C#

// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Web.Common.AspNetCore;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
[TestFixture]
public class AspNetCoreCookieManagerTests
{
private const string CookieName = "testCookie";
private const string CookieValue = "testValue";
[Test]
public void Can_Set_Cookie()
{
var httpContext = new DefaultHttpContext();
var cookieManager = CreateCookieManager(httpContext);
cookieManager.SetCookieValue(CookieName, CookieValue, true, true, "Strict");
Assert.AreEqual(GetExpectedCookie(), httpContext.Response.Headers.SetCookie);
}
[Test]
public void Set_Cookie_With_Invalid_Same_Site_Value_Throws_Expected_Exception()
{
var httpContext = new DefaultHttpContext();
var cookieManager = CreateCookieManager(httpContext);
Assert.Throws<ArgumentException>(() => cookieManager.SetCookieValue(CookieName, CookieValue, true, true, "invalid"));
}
[Test]
public void Can_Get_Cookie()
{
var httpContext = new DefaultHttpContext();
AddCookieToRequest(httpContext);
var cookieManager = CreateCookieManager(httpContext);
var result = cookieManager.GetCookieValue(CookieName);
Assert.AreEqual(CookieValue, result);
}
[Test]
public void Can_Verify_Cookie_Exists()
{
var httpContext = new DefaultHttpContext();
AddCookieToRequest(httpContext);
var cookieManager = CreateCookieManager(httpContext);
var result = cookieManager.HasCookie(CookieName);
Assert.IsTrue(result);
}
[Test]
public void Can_Expire_Cookie()
{
var httpContext = new DefaultHttpContext();
AddCookieToRequest(httpContext);
var cookieManager = CreateCookieManager(httpContext);
cookieManager.SetCookieValue(CookieName, CookieValue, true, true, "Strict");
cookieManager.ExpireCookie(CookieName);
var setCookieHeader = httpContext.Response.Headers.SetCookie.ToString();
Assert.IsTrue(setCookieHeader.StartsWith("testCookie="));
Assert.IsTrue(setCookieHeader.Contains($"expires="));
}
private static AspNetCoreCookieManager CreateCookieManager(DefaultHttpContext httpContext)
{
var httpContextAccessor = Mock.Of<IHttpContextAccessor>(x => x.HttpContext == httpContext);
return new AspNetCoreCookieManager(httpContextAccessor);
}
private static void AddCookieToRequest(DefaultHttpContext httpContext)
{
var cookie = new StringValues(CookieName + "=" + CookieValue);
httpContext.Request.Headers.Append(HeaderNames.Cookie, cookie);
}
private static string GetExpectedCookie() => $"testCookie={CookieValue}; path=/; secure; samesite=strict; httponly";
}