Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions InertiaCore/Inertia.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ public static class Inertia
public static LazyProp Lazy(Func<object?> callback) => _factory.Lazy(callback);

public static LazyProp Lazy(Func<Task<object?>> callback) => _factory.Lazy(callback);

public static void ClearHistory(bool clear = true) => _factory.ClearHistory(clear);

public static void EncryptHistory(bool encrypt = true) => _factory.EncryptHistory(encrypt);
}
24 changes: 20 additions & 4 deletions InertiaCore/Response.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>? _viewData;

internal Response(string component, Dictionary<string, object?> 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<string, object?> props, string rootView, string? version, bool encryptHistory)
=> (_component, _props, _rootView, _version, _encryptHistory) = (component, props, rootView, version, encryptHistory);

public async Task ExecuteResultAsync(ActionContext context)
{
Expand All @@ -37,14 +36,31 @@ 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,
Version = _version,
Url = _context!.RequestedUri(),
Props = props,
EncryptHistory = _encryptHistory,
ClearHistory = _clearHistory,
ClearHistory = clearHistory,
};

page.Props["errors"] = GetErrors();
Expand Down
37 changes: 35 additions & 2 deletions InertiaCore/ResponseFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IHtmlContent> Head(dynamic model)
Expand Down Expand Up @@ -135,7 +135,40 @@ public void Share(IDictionary<string, object?> 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<bool>(true);
context.Items["inertia.clear_history"] = true;
}
else
{
context.Features.Set<bool>(false);
context.Items.Remove("inertia.clear_history");
}
}

// Always set the instance variable as fallback
_clearHistory = clear;
}

public void EncryptHistory(bool encrypt = true) => _encryptHistory = encrypt;

Expand Down
126 changes: 123 additions & 3 deletions InertiaCoreTests/UnitTestHistory.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string, byte[]>();
var sessionMock = new Mock<ISession>();

var response = _factory.Render("Test/Page", new
sessionMock.Setup(s => s.Set(It.IsAny<string>(), It.IsAny<byte[]>()))
.Callback<string, byte[]>((key, value) => sessionData[key] = value);

sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out It.Ref<byte[]?>.IsAny))
.Returns((string key, out byte[]? value) => sessionData.TryGetValue(key, out value));

sessionMock.Setup(s => s.Remove(It.IsAny<string>()))
.Callback<string>(key => sessionData.Remove(key));

// Set up HttpContext with session support
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);

var contextAccessorMock = new Mock<IHttpContextAccessor>();
contextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);

// Create factory with session support
var gateway = new Mock<IGateway>();
var options = new Mock<IOptions<InertiaOptions>>();
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"
});
Expand All @@ -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();
Expand All @@ -86,5 +116,95 @@ public async Task TestClearHistoryResult()
{ "errors", new Dictionary<string, string>(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<string, byte[]>();
var sessionMock = new Mock<ISession>();

sessionMock.Setup(s => s.Set(It.IsAny<string>(), It.IsAny<byte[]>()))
.Callback<string, byte[]>((key, value) => sessionData[key] = value);

sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out It.Ref<byte[]?>.IsAny))
.Returns((string key, out byte[]? value) => sessionData.TryGetValue(key, out value));

sessionMock.Setup(s => s.Remove(It.IsAny<string>()))
.Callback<string>(key => sessionData.Remove(key));

// Set up HttpContext with session support
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);

var contextAccessorMock = new Mock<IHttpContextAccessor>();
contextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);

// Create factory with session support
var gateway = new Mock<IGateway>();
var options = new Mock<IOptions<InertiaOptions>>();
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<JsonResult>());

var json = (result as JsonResult)?.Value;
Assert.That(json, Is.InstanceOf<Page>());

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);
}

/// <summary>
/// Prepares ActionContext with session support for testing redirect scenarios.
/// </summary>
private static ActionContext PrepareContextWithSession(HeaderDictionary? headers, ISession session)
{
var request = new Mock<HttpRequest>();
request.SetupGet(r => r.Headers).Returns(headers ?? new HeaderDictionary());

var response = new Mock<HttpResponse>();
response.SetupGet(r => r.Headers).Returns(new HeaderDictionary());

var features = new Microsoft.AspNetCore.Http.Features.FeatureCollection();

var httpContext = new Mock<HttpContext>();
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());
}
}
75 changes: 75 additions & 0 deletions InertiaCoreTests/UnitTestHistoryEncryption.cs
Original file line number Diff line number Diff line change
@@ -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<IHttpContextAccessor>().Object,
new Mock<IGateway>().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);
}
}