Skip to content

Commit

Permalink
Add support for automatically setting theme based on system settings (#…
Browse files Browse the repository at this point in the history
…57)

* Add support for automatically setting theme based on the user's system settings

* Add parameter validation
  • Loading branch information
nwoolls committed Jan 25, 2023
1 parent 18327c2 commit 26486ad
Show file tree
Hide file tree
Showing 7 changed files with 278 additions and 4 deletions.
32 changes: 32 additions & 0 deletions AspNetCore.ReCaptcha.Tests/HtmlContentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.IO;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;

namespace AspNetCore.ReCaptcha.Tests;

/// <summary>
/// Extension methods for <see cref="IHtmlContent"/>.
/// </summary>
public static class HtmlContentExtensions
{
/// <summary>
/// Returns the string representation of the HTML content.
/// </summary>
/// <param name="htmlContent">HTML content which can be written to a TextWriter.</param>
/// <returns>The string representation of the HTML content.</returns>
public static string ToHtmlString(this IHtmlContent htmlContent)
{
switch (htmlContent)
{
case null:
throw new ArgumentNullException(nameof(htmlContent));
case HtmlString htmlString:
return htmlString.Value;
}

using var writer = new StringWriter();
htmlContent.WriteTo(writer, HtmlEncoder.Default);
return writer.ToString();
}
}
33 changes: 33 additions & 0 deletions AspNetCore.ReCaptcha.Tests/ReCaptchaGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,38 @@ public void ReCaptchaGeneratorReturnsReCaptchaForV3()

Assert.NotNull(result);
}

[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
public void ReCaptchaV2ConsidersAutoThemeArgument(bool autoTheme, bool expectScript)
{
// Arrange
var baseUrl = new Uri("https://www.google.com/recaptcha/");
const string siteKey = "test";
const string size = "test";
const string theme = "test";
const string language = "test";
const string callback = "test";
const string errorCallback = "test";
const string expiredCallback = "test";

// Act
IHtmlContent htmlContent = ReCaptchaGenerator.ReCaptchaV2(baseUrl, siteKey, size, theme, language,
callback, errorCallback, expiredCallback, autoTheme);

// Assert
Assert.NotNull(htmlContent);
string htmlString = htmlContent.ToHtmlString();
const string mediaQueryString = "prefers-color-scheme";
if (expectScript)
{
Assert.Contains(mediaQueryString, htmlString);
}
else
{
Assert.DoesNotContain(mediaQueryString, htmlString);
}
}
}
}
78 changes: 78 additions & 0 deletions AspNetCore.ReCaptcha.Tests/ReCaptchaHelperTests.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,54 @@
using System;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;

namespace AspNetCore.ReCaptcha.Tests;

public class ReCaptchaHelperTests
{
private readonly IHtmlHelper _htmlHelper;

public ReCaptchaHelperTests()
{
// Setup mocks
var reCaptchaSettings = new ReCaptchaSettings();

var mockReCaptchaSettingsSnapshot = new Mock<IOptionsSnapshot<ReCaptchaSettings>>();
mockReCaptchaSettingsSnapshot.Setup(m => m.Value)
.Returns(reCaptchaSettings);
var reCaptchaSettingsSnapshot = mockReCaptchaSettingsSnapshot.Object;

var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IOptions<ReCaptchaSettings>>(reCaptchaSettingsSnapshot);

ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider();

var featureCollection = new FeatureCollection();

var mockHttpContext = new Mock<HttpContext>();
mockHttpContext.Setup(m => m.RequestServices)
.Returns(serviceProvider);
mockHttpContext.Setup(m => m.Features)
.Returns(featureCollection);
HttpContext httpContext = mockHttpContext.Object;

var viewContext = new ViewContext
{
HttpContext = httpContext
};

var mockHtmlHelper = new Mock<IHtmlHelper>();
mockHtmlHelper.Setup(m => m.ViewContext)
.Returns(viewContext);
_htmlHelper = mockHtmlHelper.Object;
}

[Fact]
public void AddReCaptcha_DefaultValues()
{
Expand Down Expand Up @@ -62,4 +104,40 @@ public void AddReCaptcha_InvalidBaseUri(string url, string message)

Assert.Equal(message, ex.Message);
}

[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
public void ReCaptchaConsidersAutoThemeArgument(bool autoTheme, bool expectScript)
{
// Arrange
const string text = "foo";
const string className = "foo";
const string size = "foo";
const string theme = "foo";
const string action = "foo";
const string language = "foo";
const string id = "foo";
const string badge = "foo";
const string callback = "foo";
const string errorCallback = "foo";
const string expiredCallback = "foo";

// Act
IHtmlContent htmlContent = _htmlHelper.ReCaptcha(text, className, size, theme, action, language, id, badge, callback,
errorCallback, expiredCallback, autoTheme);

// Assert
Assert.NotNull(htmlContent);
string htmlString = htmlContent.ToHtmlString();
const string mediaQueryString = "prefers-color-scheme";
if (expectScript)
{
Assert.Contains(mediaQueryString, htmlString);
}
else
{
Assert.DoesNotContain(mediaQueryString, htmlString);
}
}
}
102 changes: 102 additions & 0 deletions AspNetCore.ReCaptcha.Tests/ReCaptchaTagHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;

namespace AspNetCore.ReCaptcha.Tests;

public class ReCaptchaTagHelperTests
{
private readonly ReCaptchaTagHelper _reCaptchaTagHelper;
private readonly ReCaptchaSettings _reCaptchaSettings;

public ReCaptchaTagHelperTests()
{
// Setup mocks
_reCaptchaSettings = new ReCaptchaSettings();

var mockReCaptchaSettingsSnapshot = new Mock<IOptionsSnapshot<ReCaptchaSettings>>();
mockReCaptchaSettingsSnapshot.Setup(m => m.Value)
.Returns(_reCaptchaSettings);
var reCaptchaSettingsSnapshot = mockReCaptchaSettingsSnapshot.Object;

var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IOptions<ReCaptchaSettings>>(reCaptchaSettingsSnapshot);

ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider();

var featureCollection = new FeatureCollection();

var mockHttpContext = new Mock<HttpContext>();
mockHttpContext.Setup(m => m.RequestServices)
.Returns(serviceProvider);
mockHttpContext.Setup(m => m.Features)
.Returns(featureCollection);
HttpContext httpContext = mockHttpContext.Object;

var viewContext = new ViewContext
{
HttpContext = httpContext
};

// Setup SUT
_reCaptchaTagHelper = new ReCaptchaTagHelper
{
ViewContext = viewContext
};
}

[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
public void ProcessConsidersAutoThemeSetting(bool autoTheme, bool expectScript)
{
// Arrange
TagHelperContext tagHelperContext = CreateTagHelperContext();
TagHelperOutput tagHelperOutput = CreateTagHelperOutput();

_reCaptchaTagHelper.AutoTheme = autoTheme;

// Act
_reCaptchaTagHelper.Process(tagHelperContext, tagHelperOutput);

// Assert
Assert.True(tagHelperOutput.Content.IsModified);
string htmlString = tagHelperOutput.Content.GetContent();
const string mediaQueryString = "prefers-color-scheme";
if (expectScript)
{
Assert.Contains(mediaQueryString, htmlString);
}
else
{
Assert.DoesNotContain(mediaQueryString, htmlString);
}
}

private static TagHelperOutput CreateTagHelperOutput()
{
const string tagName = "foo-bar";
var attributes = new TagHelperAttributeList();
Task<TagHelperContent> GetChildContentAsync(bool useCachedResult, HtmlEncoder encoder) =>
Task.FromResult<TagHelperContent>(new DefaultTagHelperContent());

return new TagHelperOutput(tagName, attributes, GetChildContentAsync);
}

private static TagHelperContext CreateTagHelperContext()
{
var allAttributes = new TagHelperAttributeList();
var items = new Dictionary<object, object>();
const string uniqueId = "fizz-buzz";

return new TagHelperContext(allAttributes, items, uniqueId);
}
}
23 changes: 22 additions & 1 deletion AspNetCore.ReCaptcha/ReCaptchaGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,21 @@ public static int GenerateId(ViewContext viewContext)
return id;
}

public static IHtmlContent ReCaptchaV2(Uri baseUrl, string siteKey, string size, string theme, string language, string callback, string errorCallback, string expiredCallback)
/// <summary>
/// Renders the Google ReCaptcha v2 HTML.
/// </summary>
/// <param name="baseUrl">The base URL where the Google Recaptcha JS script is hosted.</param>
/// <param name="siteKey">The site key.</param>
/// <param name="size">Optional parameter, contains the size of the widget.</param>
/// <param name="theme">Google Recaptcha theme default is light.</param>
/// <param name="language">Google Recaptcha <a href="https://developers.google.com/recaptcha/docs/language">Language Code</a></param>
/// <param name="callback">Google ReCaptcha success callback method. Used in v2 ReCaptcha.</param>
/// <param name="errorCallback">Google ReCaptcha error callback method. Used in v2 ReCaptcha.</param>
/// <param name="expiredCallback">Google ReCaptcha expired callback method. Used in v2 ReCaptcha.</param>
/// <param name="autoTheme">Indicates whether the theme is automatically set to 'dark' based on the user's system settings.</param>
/// <returns></returns>
public static IHtmlContent ReCaptchaV2(Uri baseUrl, string siteKey, string size, string theme, string language,
string callback, string errorCallback, string expiredCallback, bool autoTheme = false)
{
var content = new HtmlContentBuilder();
content.AppendFormat(@"<div class=""g-recaptcha"" data-sitekey=""{0}""", siteKey);
Expand All @@ -38,6 +52,13 @@ public static IHtmlContent ReCaptchaV2(Uri baseUrl, string siteKey, string size,
content.AppendLine();
content.AppendFormat(@"<script src=""{0}api.js?hl={1}"" defer></script>", baseUrl, language);

if (autoTheme)
{
content
.AppendLine()
.AppendHtmlLine("<script>window.matchMedia('(prefers-color-scheme: dark)').matches&&document.querySelector('.g-recaptcha').setAttribute('data-theme','dark');</script>");
}

return content;
}

Expand Down
6 changes: 4 additions & 2 deletions AspNetCore.ReCaptcha/ReCaptchaHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public static IServiceCollection AddReCaptcha(this IServiceCollection services,
/// <param name="callback">Google ReCaptcha success callback method. Used in v2 ReCaptcha.</param>
/// <param name="errorCallback">Google ReCaptcha error callback method. Used in v2 ReCaptcha.</param>
/// <param name="expiredCallback">Google ReCaptcha expired callback method. Used in v2 ReCaptcha.</param>
/// <param name="autoTheme">Indicates whether the theme is automatically set to 'dark' based on the user's system settings.</param>
/// <returns>HtmlString with Recaptcha elements</returns>
public static IHtmlContent ReCaptcha(
this IHtmlHelper helper,
Expand All @@ -108,7 +109,8 @@ public static IServiceCollection AddReCaptcha(this IServiceCollection services,
string badge = "bottomright",
string callback = null,
string errorCallback = null,
string expiredCallback = null)
string expiredCallback = null,
bool autoTheme = false)
{
if (string.IsNullOrEmpty(id))
throw new ArgumentException("id can't be null");
Expand All @@ -125,7 +127,7 @@ public static IServiceCollection AddReCaptcha(this IServiceCollection services,
{
default:
case ReCaptchaVersion.V2:
return ReCaptchaGenerator.ReCaptchaV2(settings.RecaptchaBaseUrl, settings.SiteKey, size, theme, language, callback, errorCallback, expiredCallback);
return ReCaptchaGenerator.ReCaptchaV2(settings.RecaptchaBaseUrl, settings.SiteKey, size, theme, language, callback, errorCallback, expiredCallback, autoTheme);
case ReCaptchaVersion.V2Invisible:
return ReCaptchaGenerator.ReCaptchaV2Invisible(settings.RecaptchaBaseUrl, settings.SiteKey, text, className, language, callback, badge);
case ReCaptchaVersion.V3:
Expand Down
8 changes: 7 additions & 1 deletion AspNetCore.ReCaptcha/ReCaptchaTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public class ReCaptchaTagHelper : TagHelper
public string ErrorCallback { get; set; } = null;
public string ExpiredCallback { get; set; } = null;

/// <summary>
/// Indicates whether the theme is automatically set to 'dark' based on the user's system settings.
/// </summary>
[HtmlAttributeName("auto-theme")]
public bool AutoTheme { get; set; }

[ViewContext]
public ViewContext ViewContext { get; set; }

Expand All @@ -46,7 +52,7 @@ public override void Process(TagHelperContext context, TagHelperOutput output)
{
ReCaptchaVersion.V2Invisible => ReCaptchaGenerator.ReCaptchaV2Invisible(settings.RecaptchaBaseUrl, settings.SiteKey, Text, ClassName, Language, Callback, Badge),
ReCaptchaVersion.V3 => ReCaptchaGenerator.ReCaptchaV3(settings.RecaptchaBaseUrl, settings.SiteKey, Action, Language, Callback, ReCaptchaGenerator.GenerateId(ViewContext)),
_ => ReCaptchaGenerator.ReCaptchaV2(settings.RecaptchaBaseUrl, settings.SiteKey, Size, Theme, Language, Callback, ErrorCallback, ExpiredCallback),
_ => ReCaptchaGenerator.ReCaptchaV2(settings.RecaptchaBaseUrl, settings.SiteKey, Size, Theme, Language, Callback, ErrorCallback, ExpiredCallback, AutoTheme),
};

output.Content.AppendHtml(content);
Expand Down

0 comments on commit 26486ad

Please sign in to comment.