diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index d13b932..a228780 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -40,4 +40,8 @@ public static class Inertia public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); + + public static void ClearHistory(bool clear = true) => _factory.ClearHistory(clear); + + public static void EncryptHistory(bool encrypt = true) => _factory.EncryptHistory(encrypt); } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 7bdd2cb..c267fad 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -17,14 +17,13 @@ public class Response : IActionResult private readonly string _rootView; private readonly string? _version; private readonly bool _encryptHistory; - private readonly bool _clearHistory; private ActionContext? _context; private Page? _page; private IDictionary? _viewData; - internal Response(string component, Dictionary props, string rootView, string? version, bool encryptHistory, bool clearHistory) - => (_component, _props, _rootView, _version, _encryptHistory, _clearHistory) = (component, props, rootView, version, encryptHistory, clearHistory); + internal Response(string component, Dictionary props, string rootView, string? version, bool encryptHistory) + => (_component, _props, _rootView, _version, _encryptHistory) = (component, props, rootView, version, encryptHistory); public async Task ExecuteResultAsync(ActionContext context) { @@ -37,6 +36,23 @@ protected internal async Task ProcessResponse() { var props = await ResolveProperties(); + // Pull clearHistory from session storage + var clearHistory = false; + + try + { + var session = _context!.HttpContext.Session; + if (session != null && session.TryGetValue("inertia.clear_history", out _)) + { + clearHistory = true; + session.Remove("inertia.clear_history"); + } + } + catch + { + // Session not available, clearHistory will remain false + } + var page = new Page { Component = _component, @@ -44,7 +60,7 @@ protected internal async Task ProcessResponse() Url = _context!.RequestedUri(), Props = props, EncryptHistory = _encryptHistory, - ClearHistory = _clearHistory, + ClearHistory = clearHistory, }; page.Props["errors"] = GetErrors(); diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 534b7ba..2735f25 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -54,7 +54,7 @@ public Response Render(string component, object? props = null) .ToDictionary(o => o.Name, o => o.GetValue(props)) }; - return new Response(component, dictProps, _options.Value.RootView, GetVersion(), _encryptHistory ?? _options.Value.EncryptHistory, _clearHistory); + return new Response(component, dictProps, _options.Value.RootView, GetVersion(), _encryptHistory ?? _options.Value.EncryptHistory); } public async Task Head(dynamic model) @@ -135,7 +135,40 @@ public void Share(IDictionary data) context.Features.Set(sharedData); } - public void ClearHistory(bool clear = true) => _clearHistory = clear; + public void ClearHistory(bool clear = true) + { + var context = _contextAccessor.HttpContext; + + // Try to use session first (preferred for production to survive redirects) + if (context?.Session != null) + { + if (clear) + { + context.Session.SetString("inertia.clear_history", "true"); + } + else + { + context.Session.Remove("inertia.clear_history"); + } + } + else if (context?.Features != null) + { + // Fallback for test scenarios: store in request features + if (clear) + { + context.Features.Set(true); + context.Items["inertia.clear_history"] = true; + } + else + { + context.Features.Set(false); + context.Items.Remove("inertia.clear_history"); + } + } + + // Always set the instance variable as fallback + _clearHistory = clear; + } public void EncryptHistory(bool encrypt = true) => _encryptHistory = encrypt; diff --git a/InertiaCoreTests/UnitTestHistory.cs b/InertiaCoreTests/UnitTestHistory.cs index cb81347..8efa9eb 100644 --- a/InertiaCoreTests/UnitTestHistory.cs +++ b/InertiaCoreTests/UnitTestHistory.cs @@ -1,6 +1,10 @@ +using InertiaCore; using InertiaCore.Models; +using InertiaCore.Ssr; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; namespace InertiaCoreTests; @@ -51,9 +55,35 @@ public async Task TestHistoryEncryptionResult() [Description("Test if clear history is sent correctly.")] public async Task TestClearHistoryResult() { - _factory.ClearHistory(); + // Set up session mock + var sessionData = new Dictionary(); + var sessionMock = new Mock(); - var response = _factory.Render("Test/Page", new + sessionMock.Setup(s => s.Set(It.IsAny(), It.IsAny())) + .Callback((key, value) => sessionData[key] = value); + + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns((string key, out byte[]? value) => sessionData.TryGetValue(key, out value)); + + sessionMock.Setup(s => s.Remove(It.IsAny())) + .Callback(key => sessionData.Remove(key)); + + // Set up HttpContext with session support + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var contextAccessorMock = new Mock(); + contextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + // Create factory with session support + var gateway = new Mock(); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new InertiaOptions()); + var factoryWithSession = new ResponseFactory(contextAccessorMock.Object, gateway.Object, options.Object); + + factoryWithSession.ClearHistory(); + + var response = factoryWithSession.Render("Test/Page", new { Test = "Test" }); @@ -63,7 +93,7 @@ public async Task TestClearHistoryResult() { "X-Inertia", "true" } }; - var context = PrepareContext(headers); + var context = PrepareContextWithSession(headers, sessionMock.Object); response.SetContext(context); await response.ProcessResponse(); @@ -86,5 +116,95 @@ public async Task TestClearHistoryResult() { "errors", new Dictionary(0) } })); }); + + // Verify session value was removed after being read (one-time use behavior) + sessionMock.Verify(s => s.Remove("inertia.clear_history"), Times.Once); + } + + [Test] + [Description("Test if clear history persists when redirecting.")] + public async Task TestClearHistoryWithRedirect() + { + // Arrange: Set up session mock to simulate session storage behavior + var sessionData = new Dictionary(); + var sessionMock = new Mock(); + + sessionMock.Setup(s => s.Set(It.IsAny(), It.IsAny())) + .Callback((key, value) => sessionData[key] = value); + + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns((string key, out byte[]? value) => sessionData.TryGetValue(key, out value)); + + sessionMock.Setup(s => s.Remove(It.IsAny())) + .Callback(key => sessionData.Remove(key)); + + // Set up HttpContext with session support + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var contextAccessorMock = new Mock(); + contextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + // Create factory with session support + var gateway = new Mock(); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new InertiaOptions()); + var factoryWithSession = new ResponseFactory(contextAccessorMock.Object, gateway.Object, options.Object); + + // Simulate first request: set clearHistory and redirect + factoryWithSession.ClearHistory(); + + // Simulate second request after redirect: create new response + var response = factoryWithSession.Render("User/Edit", new { }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" } + }; + + var context = PrepareContextWithSession(headers, sessionMock.Object); + + response.SetContext(context); + await response.ProcessResponse(); + + var result = response.GetResult(); + + // Assert: clearHistory should persist through redirect + Assert.Multiple(() => + { + Assert.That(result, Is.InstanceOf()); + + var json = (result as JsonResult)?.Value; + Assert.That(json, Is.InstanceOf()); + + Assert.That((json as Page)?.ClearHistory, Is.EqualTo(true)); + Assert.That((json as Page)?.EncryptHistory, Is.EqualTo(false)); + Assert.That((json as Page)?.Component, Is.EqualTo("User/Edit")); + }); + + // Verify session value was removed after being read (one-time use behavior) + sessionMock.Verify(s => s.Remove("inertia.clear_history"), Times.Once); + } + + /// + /// Prepares ActionContext with session support for testing redirect scenarios. + /// + private static ActionContext PrepareContextWithSession(HeaderDictionary? headers, ISession session) + { + var request = new Mock(); + request.SetupGet(r => r.Headers).Returns(headers ?? new HeaderDictionary()); + + var response = new Mock(); + response.SetupGet(r => r.Headers).Returns(new HeaderDictionary()); + + var features = new Microsoft.AspNetCore.Http.Features.FeatureCollection(); + + var httpContext = new Mock(); + httpContext.SetupGet(c => c.Request).Returns(request.Object); + httpContext.SetupGet(c => c.Response).Returns(response.Object); + httpContext.SetupGet(c => c.Features).Returns(features); + httpContext.SetupGet(c => c.Session).Returns(session); + + return new ActionContext(httpContext.Object, new Microsoft.AspNetCore.Routing.RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()); } } diff --git a/InertiaCoreTests/UnitTestHistoryEncryption.cs b/InertiaCoreTests/UnitTestHistoryEncryption.cs new file mode 100644 index 0000000..da34085 --- /dev/null +++ b/InertiaCoreTests/UnitTestHistoryEncryption.cs @@ -0,0 +1,75 @@ +using InertiaCore; +using InertiaCore.Models; +using InertiaCore.Ssr; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; + +namespace InertiaCoreTests; + +[TestFixture] +public class UnitTestHistoryEncryption +{ + [SetUp] + public void Setup() + { + // Set up a factory for testing + var options = new InertiaOptions(); + var factory = new ResponseFactory( + new Mock().Object, + new Mock().Object, + Options.Create(options) + ); + + Inertia.UseFactory(factory); + } + + [Test] + public void Inertia_EncryptHistory_MethodExists() + { + // This test verifies the API exists and can be called + Assert.DoesNotThrow(() => Inertia.EncryptHistory(true)); + Assert.DoesNotThrow(() => Inertia.EncryptHistory(false)); + Assert.DoesNotThrow(() => Inertia.EncryptHistory()); + } + + [Test] + public void Inertia_ClearHistory_MethodExists() + { + // This test verifies the API exists and can be called + Assert.DoesNotThrow(() => Inertia.ClearHistory(true)); + Assert.DoesNotThrow(() => Inertia.ClearHistory(false)); + Assert.DoesNotThrow(() => Inertia.ClearHistory()); + } + + + [Test] + public void Page_HasHistoryEncryptionProperties() + { + // This test verifies the Page model has the required properties + var page = new Page(); + + Assert.That(page.EncryptHistory, Is.False); // Default value + Assert.That(page.ClearHistory, Is.False); // Default value + + page.EncryptHistory = true; + page.ClearHistory = true; + + Assert.That(page.EncryptHistory, Is.True); + Assert.That(page.ClearHistory, Is.True); + } + + [Test] + public void InertiaOptions_HasEncryptHistoryProperty() + { + // This test verifies the options class has the required property + var options = new InertiaOptions(); + + Assert.That(options.EncryptHistory, Is.False); // Default value + + options.EncryptHistory = true; + Assert.That(options.EncryptHistory, Is.True); + } +} \ No newline at end of file