Files
james.maesandClaude Fable 5.2 a1320e489e
node / node (push) Successful in 12s
build / scripts (push) Successful in 12s
windows / windows (push) Successful in 24m33s
build / windows (push) Successful in 24m33s
fix(themes): validate theme text and pin the untrusted-input boundaries (#49)
Pins the sanitizer allowlist, the resource-policy boundaries and Windows file-name rules with exact tests. Themes with undecodable text or repeated property names are refused; renamed and edited themes are written compactly and kept under their own size limit.

Closes #48

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 00:32:28 +00:00

214 lines
8.7 KiB
C#

/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Exercises the original themes, schema errors and CSS/HTML boundary with hostile theme input.</summary>
public sealed class ReaderThemeTests
{
/// <summary>The complete bundled collection is usable and retains Darin's default.</summary>
[Fact]
public void BuiltIn_OriginalResources_LoadsTwentyThemes()
{
Assert.Equal(20, ThemeCatalog.BuiltIn.Count);
Assert.Equal("Catppuccin Mocha", ThemeCatalog.Find(null).Name);
Assert.Equal("Catppuccin Mocha", ThemeCatalog.Find("removed theme").Name);
Assert.Equal("GitHub Light", ThemeCatalog.Find("GitHub Light").Name);
Assert.Equal(ThemeCatalog.BuiltIn.Select(theme => theme.Name).Order(StringComparer.Ordinal), ThemeCatalog.BuiltIn.Select(theme => theme.Name), StringComparer.Ordinal);
Assert.All(ThemeCatalog.BuiltIn, theme => Assert.Contains("var(--background)", theme.Styles, StringComparison.Ordinal));
}
/// <summary>User edits replace bundled names without duplicates; new themes are sorted and retained.</summary>
[Fact]
public void Combine_UserOverridesAndNewTheme_PreservesEdits()
{
string original = OriginalJson();
var edited = ReaderTheme.Parse(original.Replace("#1e1e2e", "#123456", StringComparison.Ordinal));
var custom = ReaderTheme.Parse(original.Replace("Catppuccin Mocha", "A Custom Theme", StringComparison.Ordinal));
IReadOnlyList<ReaderTheme> combined = ThemeCatalog.Combine([edited, custom]);
Assert.Equal(21, combined.Count);
Assert.Same(custom, combined[0]);
Assert.Same(edited, combined.Single(theme => string.Equals(theme.Name, edited.Name, StringComparison.Ordinal)));
Assert.Contains("--background:#123456;", edited.Styles, StringComparison.Ordinal);
Assert.Equal(original.Replace("#1e1e2e", "#123456", StringComparison.Ordinal), edited.Json);
Assert.Equal(20, ThemeCatalog.Combine([]).Count);
Assert.Throws<ArgumentNullException>(() => ThemeCatalog.Combine(null!));
}
/// <summary>Preferences discard corrupt custom themes while retaining valid edits and repairing invalid selection names.</summary>
[Fact]
public void Sanitize_CorruptSavedThemes_KeepsValidThemes()
{
string json = OriginalJson();
var settings = new AppSettings { DocumentTheme = "\n", UserThemes = [null!, "", "{", json] };
settings.Sanitize();
Assert.Equal("Catppuccin Mocha", settings.DocumentTheme);
Assert.Equal([json], settings.UserThemes, StringComparer.Ordinal);
settings.UserThemes = null!;
settings.DocumentTheme = new string('x', 81);
settings.Sanitize();
Assert.Empty(settings.UserThemes);
Assert.Equal("Catppuccin Mocha", settings.DocumentTheme);
}
/// <summary>Color, font and size values become CSS variables using invariant numbers.</summary>
[Fact]
public void Parse_OriginalTheme_PreservesPaletteAndMapsFonts()
{
CultureInfo previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
var theme = ReaderTheme.Parse(OriginalJson());
Assert.Equal("Catppuccin Mocha", theme.Name);
Assert.Contains("--background:#1e1e2e;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--bodyFont:system-ui, sans-serif;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--codeFont:Consolas, 'Cascadia Code', monospace;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--baseFontSize:14px;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--lineHeight:1.7;", theme.Styles, StringComparison.Ordinal);
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
/// <summary>Custom font and CSS values cannot escape the containing style element or inject font declarations.</summary>
[Fact]
public void Parse_CustomStrings_EscapesHtmlAndFontDelimiters()
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
root["schemaVersion"] = 1;
root["fonts"]!["body"] = "A';} <script>";
root["customCSS"] = "body { color: red; } </style><script>alert(1)</script>";
string styles = ReaderTheme.Parse(root.ToJsonString()).Styles;
Assert.DoesNotContain("<", styles, StringComparison.Ordinal);
Assert.Contains("--bodyFont:'\\41 \\27 \\3B \\7D \\20 \\3C \\73 \\63 \\72 \\69 \\70 \\74 \\3E ';", styles, StringComparison.Ordinal);
Assert.Contains("\\3c /style>", styles, StringComparison.Ordinal);
}
/// <summary>Optional custom CSS is not required by older theme files.</summary>
[Fact]
public void Parse_NoCustomCss_LoadsTheme()
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
root.AsObject().Remove("customCSS");
Assert.Equal("Catppuccin Mocha", ReaderTheme.Parse(root.ToJsonString()).Name);
}
/// <summary>Malformed JSON, oversized files and non-object roots fail before generating CSS.</summary>
[Theory]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{")]
public void Parse_InvalidRoot_RejectsInput(string json) => Assert.ThrowsAny<JsonException>(() => ReaderTheme.Parse(json));
/// <summary>Null input is refused, and a theme is kept up to the stored size limit exactly and refused one character past it.</summary>
[Fact]
public void Parse_OutsideSizeContract_RejectsInput()
{
Assert.Throws<ArgumentNullException>(() => ReaderTheme.Parse(null!));
string valid = ThemeCatalog.Find("Nord").Json;
Assert.Equal("Nord", ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters)).Name);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters + 1)));
Assert.Equal(8 * 128 * 1024, ReaderTheme.MaximumStoredCharacters);
}
/// <summary>Required objects, strings, version, numeric bounds and colors cannot carry invalid schema values.</summary>
[Theory]
[InlineData("schemaVersion", "2")]
[InlineData("schemaVersion", "1.5")]
[InlineData("schemaVersion", "\"1\"")]
[InlineData("name", "\"\"")]
[InlineData("name", "\"\\n\"")]
[InlineData("name", "null")]
[InlineData("colors", "null")]
[InlineData("colors.background", "\"red\"")]
[InlineData("colors.background", "\"#zzzzzz\"")]
[InlineData("colors.background", "\";color:red\"")]
[InlineData("fonts.body", "\"\"")]
[InlineData("fonts.code", "\"\\n\"")]
[InlineData("sizes.baseFontSize", "5")]
[InlineData("sizes.h1Size", "97")]
[InlineData("sizes.h2Size", "1e400")]
[InlineData("sizes.h3Size", "\"20\"")]
[InlineData("sizes.lineHeight", "0.4")]
[InlineData("sizes.lineHeight", "4.1")]
[InlineData("customCSS", "false")]
public void Parse_InvalidProperty_RejectsInput(string path, string json)
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent[parts[^1]] = JsonNode.Parse(json);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
/// <summary>Missing fields never reach unchecked JSON property access.</summary>
[Theory]
[InlineData("name")]
[InlineData("colors")]
[InlineData("fonts")]
[InlineData("sizes")]
[InlineData("colors.text")]
[InlineData("sizes.h4Size")]
public void Parse_MissingProperty_RejectsInput(string path)
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent.AsObject().Remove(parts[^1]);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
/// <summary>Names and font families cannot produce unbounded CSS or inaccessible labels.</summary>
[Theory]
[InlineData("name")]
[InlineData("fonts.body")]
public void Parse_OverlongLabel_RejectsInput(string path)
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent[parts[^1]] = new string('A', 81);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
/// <summary>Loads the exact bundled reference instead of maintaining a duplicate test schema.</summary>
private static string OriginalJson()
{
using Stream stream = typeof(ReaderTheme).Assembly.GetManifestResourceStream("MarkdownViewr.Windows.Core.Themes.BuiltIn.catppuccin-mocha.json")!;
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}