Skip to content
Merged
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
63 changes: 62 additions & 1 deletion Redot-Documentation-Tests/ClassDocumentationComponentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,54 @@ public async Task ClassReference_IncludesUpstreamMitAttribution(string? classNam
Assert.Empty(renderer.Errors);
}

[Theory]
[InlineData("26.1/tutorials/MIXED.HTML")]
[InlineData("26.1/TUTORIALS/mixed.MD")]
[InlineData("tutorials/mixed")]
public async Task Manual_LegacyAliasesRenderAndHeadingLinksPreserveQuery(string route)
{
Directory.CreateDirectory(Path.Combine(_root, "docs", "26.1", "Tutorials"));
File.WriteAllText(Path.Combine(_root, "docs", "26.1", "Tutorials", "Mixed.md"), "# Mixed heading");
await using var services = CreateServices();
services.GetRequiredService<NavigationManager>().NavigateTo("/en/" + route + "?source=Legacy#Mixed");
await using var renderer = new TestRenderer(services);
int id = await renderer.Dispatcher.InvokeAsync(() => renderer.RenderAsync(typeof(DocViewer),
ParameterView.FromDictionary(new Dictionary<string, object?> { ["DocumentPath"] = route })));
Assert.Contains("Mixed heading", await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id)));
Assert.Contains("/en/26.1/Tutorials/Mixed?source=Legacy#mixed-heading",
await renderer.Dispatcher.InvokeAsync(() => renderer.Hrefs(id).ToArray()));
Assert.Empty(renderer.Errors);
}

[Theory]
[InlineData("/en/26.1/tutorials/MIXED.HTML?source=Legacy#SomeHeading", "/en/latest/Tutorials/Mixed?source=Legacy#SomeHeading")]
[InlineData("/en/tutorials/mixed.md?source=Legacy#SomeHeading", "/en/latest/Tutorials/Mixed?source=Legacy#SomeHeading")]
[InlineData("/en/about/INTRO.HTML?source=Legacy#SomeHeading", "/en/About/Intro?source=Legacy#SomeHeading")]
public async Task NavMenu_VersionSwitchResolvesAliasesAndPreservesQueryAndFragment(string source, string expected)
{
foreach (var version in new[] { "26.1", "latest" })
{
Directory.CreateDirectory(Path.Combine(_root, "docs", version, "Tutorials"));
File.WriteAllText(Path.Combine(_root, "docs", version, "Tutorials", "Mixed.md"), "# Mixed");
}
Directory.CreateDirectory(Path.Combine(_root, "docs", "About"));
File.WriteAllText(Path.Combine(_root, "docs", "About", "Intro.md"), "# Intro");
await using var services = CreateServices();
var navigation = services.GetRequiredService<NavigationManager>();
navigation.NavigateTo(source);
await using var renderer = new TestRenderer(services);
int id = await renderer.Dispatcher.InvokeAsync(() => renderer.RenderAsync(typeof(NavMenu), ParameterView.Empty));
Assert.Contains("doc-current", await renderer.Dispatcher.InvokeAsync(() => renderer.CssClasses(id).ToArray()));
await renderer.Dispatcher.InvokeAsync(async () =>
{
var select = renderer.FindComponent<MudBlazor.MudSelect<string>>(id);
Assert.NotNull(select);
await select.ValueChanged.InvokeAsync("latest");
});
Assert.Equal("http://localhost" + expected, navigation.Uri);
Assert.Empty(renderer.Errors);
}

[Fact]
public async Task Manual_IncludesCcAttributionAndModificationNotice()
{
Expand Down Expand Up @@ -265,7 +313,8 @@ private ServiceProvider CreateServices(IDocumentationSearch? search = null)
return new ServiceCollection().AddLogging()
.AddSingleton<IDocumentationSearch>(search ?? new ControlledSearch())
.AddMudServices(options => options.PopoverOptions.CheckForPopoverProvider = false)
.AddSingleton<DocRendererService>(new DocRendererService(new TestEnvironment(_root)))
.AddSingleton(_ => new DocumentPathResolver(new TestEnvironment(_root), manager))
.AddSingleton<DocRendererService>()
.AddSingleton(manager)
.AddSingleton<ClassDocumentationCatalog>()
.AddSingleton<ClassDocumentationRenderer>()
Expand Down Expand Up @@ -306,6 +355,18 @@ public IEnumerable<string> Hrefs(int id)
}
}

public IEnumerable<string> CssClasses(int id)
{
var frames = GetCurrentRenderTreeFrames(id);
foreach (var frame in frames.Array.Take(frames.Count))
{
if (frame.FrameType == RenderTreeFrameType.Attribute && frame.AttributeName == "class" && frame.AttributeValue is string css)
foreach (var name in css.Split(' ', StringSplitOptions.RemoveEmptyEntries)) yield return name;
if (frame.FrameType == RenderTreeFrameType.Component)
foreach (var name in CssClasses(frame.ComponentId)) yield return name;
}
}

public T? FindComponent<T>(int id) where T : class
{
var frames = GetCurrentRenderTreeFrames(id);
Expand Down
30 changes: 18 additions & 12 deletions Redot-Documentation-Tests/DocRendererServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ public sealed class DocRendererServiceTests : IDisposable
$"redot-documentation-tests-{Guid.NewGuid():N}");

[Theory]
[InlineData("doc_some_doc#some_section", "/en/About/some_doc.md#some_section")]
[InlineData("doc_some_doc#some-section", "/en/About/some_doc.md#some-section")]
[InlineData("doc_some_doc#Some%20Section", "/en/About/some_doc.md#Some%20Section")]
[InlineData("doc_some_doc#version-4.1", "/en/About/some_doc.md#version-4.1")]
[InlineData("doc_some_doc", "/en/About/some_doc.md")]
[InlineData("doc_some_doc#some_section", "/en/About/some_doc#some_section")]
[InlineData("doc_some_doc#some-section", "/en/About/some_doc#some-section")]
[InlineData("doc_some_doc#Some%20Section", "/en/About/some_doc#Some%20Section")]
[InlineData("doc_some_doc#version-4.1", "/en/About/some_doc#version-4.1")]
[InlineData("doc_some_doc", "/en/About/some_doc")]
public async Task RenderToHtmlAsync_ResolvesDocumentSlugsWithOptionalSections(
string target,
string expectedHref)
Expand All @@ -27,7 +27,8 @@ await File.WriteAllTextAsync(
Path.Combine(contentRootPath, "docs", "source.md"),
$"[Some Section]({target})");

var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath));
var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath),
new VersionManagerService(new TestWebHostEnvironment(contentRootPath))));

var html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider());

Expand All @@ -40,10 +41,11 @@ public async Task RenderToHtmlAsync_PreservesLiteralLinkExamples()
Directory.CreateDirectory(Path.Combine(contentRootPath, "docs"));
await File.WriteAllTextAsync(Path.Combine(contentRootPath, "docs", "source.md"),
"``[example_name](doc_some_doc)``\n\n```markdown\n[example_name](doc_some_doc)\n```");
var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath));
var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath),
new VersionManagerService(new TestWebHostEnvironment(contentRootPath))));
var html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider());
Assert.Contains("[example_name](doc_some_doc)", html);
Assert.DoesNotContain("/en/About/some_doc.md", html);
Assert.DoesNotContain("/en/About/some_doc", html);
}

[Fact]
Expand All @@ -54,7 +56,8 @@ await File.WriteAllTextAsync(
Path.Combine(contentRootPath, "docs", "source.md"),
"[Missing](doc_missing#some_section)");

var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath));
var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath),
new VersionManagerService(new TestWebHostEnvironment(contentRootPath))));

var html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider());

Expand All @@ -77,7 +80,8 @@ await File.WriteAllTextAsync(
Path.Combine(contentRootPath, "docs", "source.md"),
$"[Class reference]({target})");

var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath));
var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath),
new VersionManagerService(new TestWebHostEnvironment(contentRootPath))));

string html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider());

Expand All @@ -102,7 +106,8 @@ Nested content.
</Tabs>
""");

var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath));
var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath),
new VersionManagerService(new TestWebHostEnvironment(contentRootPath))));

string html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider());

Expand All @@ -124,7 +129,8 @@ await File.WriteAllTextAsync(Path.Combine(contentRootPath, "docs", "source.md"),
<TabItem value="C#" label="Repeated">Third sample.</TabItem>
</Tabs>
""");
var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath));
var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath),
new VersionManagerService(new TestWebHostEnvironment(contentRootPath))));
string html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider());
TabMarkupAssertions.AssertAccessibleTabs(html, 3);
}
Expand Down
197 changes: 197 additions & 0 deletions Redot-Documentation-Tests/DocumentPathResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Redot_Documentation.Services;
using Redot_Documentation.Versioning;

namespace Redot_Documentation_Tests;

public sealed class DocumentPathResolverTests : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), $"redot-path-tests-{Guid.NewGuid():N}");
private readonly VersionManagerService versions;
private readonly IWebHostEnvironment environment;

public DocumentPathResolverTests()
{
environment = new TestEnvironment(root);
Write("26.1/Tutorials/Physics/ray-casting.md", "# Stable physics");
Write("latest/Tutorials/Physics/ray-casting.md", "# Development physics");
Write("About/Introduction.md", "# Shared introduction");
Write("26.1/About/Introduction.md", "# Version introduction");
Write("About/index.json", "{\"SlugPrefix\":\"abt_\"}");
Write("26.1/Tutorials/Space ü.md", "# Unicode");
Write("26.1/OnlyStable.md", "# Only stable");
File.WriteAllText(Path.Combine(root, "docs", "Versions.json"), JsonSerializer.Serialize(new[]
{
new DocumentationVersion { Slug = "26.1", FriendlyName = "Stable", BranchName = "26.1", IsLatestStable = true },
new DocumentationVersion { Slug = "latest", FriendlyName = "Development", BranchName = "master", IsNextPrerelease = true }
}));
versions = new VersionManagerService(environment);
versions.LoadContent();
}

[Theory]
[InlineData("26.1/tutorials/PHYSICS/RAY-CASTING")]
[InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.md")]
[InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.MD")]
[InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.html")]
[InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.HTML")]
[InlineData("/26.1/tutorials/PHYSICS/RAY-CASTING.HTML/")]
[InlineData("tutorials/physics/ray-casting.html")]
public async Task AliasesResolveAndRenderTheSamePhysicalDocument(string route)
{
var paths = new DocumentPathResolver(environment, versions);
var document = Assert.IsType<ResolvedDocument>(paths.ResolveRoute(route, versions));
Assert.Equal(Path.Combine(root, "docs", "26.1", "Tutorials", "Physics", "ray-casting.md"), document.FullPath);
Assert.Equal("/en/26.1/Tutorials/Physics/ray-casting", document.PublicUrl);
var (provider, path) = DocumentPathResolver.ParseRoute(route, versions);
var html = await new DocRendererService(paths).RenderToHtmlAsync(path, provider);
Assert.Contains("Stable physics", html);
}

[Theory]
[InlineData("about/INTRODUCTION.HTML")]
[InlineData("26.1/about/INTRODUCTION.md")]
[InlineData("LATEST/about/introduction")]
public void SharedDocumentsKeepPrecedence(string route)
{
var document = new DocumentPathResolver(environment, versions).ResolveRoute(route, versions);
Assert.Equal("About/Introduction.md", document?.RelativePath);
}

[Fact]
public void VersionIsCaseInsensitiveButMissingContentDoesNotFallBackToAnotherVersion()
{
var paths = new DocumentPathResolver(environment, versions);
Assert.Equal("latest/Tutorials/Physics/ray-casting.md",
paths.ResolveRoute("LATEST/tutorials/physics/ray-casting.HTML", versions)?.RelativePath);
Assert.Null(paths.ResolveRoute("latest/OnlyStable.html", versions));
Assert.Null(paths.ResolveRoute("99.0/Tutorials/Physics/ray-casting", versions));
Assert.Null(paths.ResolveRoute("26.1/missing.html", versions));
Assert.Null(paths.ResolveRoute("26.1/Tutorials/Physics/ray-casting.htm", versions));
Assert.Null(paths.ResolveRoute("26.1/Tutorials/Physics/ray-casting.html.md", versions));
Assert.Null(paths.ResolveRoute("", versions));
}

[Theory]
[InlineData("latest/26.1/OnlyStable.html")]
[InlineData("LATEST/26.1/Tutorials/Physics/ray-casting.MD")]
[InlineData("26.1/LATEST/Tutorials/Physics/ray-casting.HTML")]
public void NestedVersionPrefixesCannotSelectAnotherVersionsDocument(string route)
{
var paths = new DocumentPathResolver(environment, versions);
Assert.Null(paths.ResolveRoute(route, versions));
}

[Theory]
[InlineData("../outside")]
[InlineData("26.1/../../docs-other/secret.md")]
[InlineData("26.1/Tutorials/./Physics/ray-casting")]
[InlineData("26.1\\Tutorials\\Physics\\ray-casting")]
[InlineData("26.1/file.md?x=1")]
[InlineData("26.1/file.html#Heading")]
[InlineData("26.1/file\0.md")]
public void RejectsInvalidPaths(string route)
=> Assert.Throws<ArgumentException>(() => new DocumentPathResolver(environment, versions).ResolveRoute(route, versions));

[Fact]
public void EncodedCharactersAreDecodedOnlyAtTheUrlBoundary()
{
var paths = new DocumentPathResolver(environment, versions);
var route = Uri.UnescapeDataString("26.1/tutorials/space%20%C3%BC.HTML");
Assert.Equal("/en/26.1/Tutorials/Space%20%C3%BC", paths.ResolveRoute(route, versions)?.PublicUrl);
Assert.Null(paths.ResolveRoute("26.1/%2e%2e/About/Introduction", versions));
}

[Fact]
public void CaseCollisionsIdentifyBothFiles()
{
// Probe the actual test volume before creating a second, differently cased file.
if (File.Exists(Path.Combine(root, "docs", "26.1", "tutorials", "physics", "RAY-CASTING.md")))
return;
Write("26.1/tutorials/physics/RAY-CASTING.md", "# Collision");
var error = Assert.Throws<InvalidOperationException>(() => new DocumentPathResolver(environment, versions));
Assert.Contains("26.1/Tutorials/Physics/ray-casting.md", error.Message);
Assert.Contains("26.1/tutorials/physics/RAY-CASTING.md", error.Message);
}

[Fact]
public void DoesNotIndexNonMarkdownOrSymbolicLinks()
{
Write("26.1/private.txt", "Private");
if (!OperatingSystem.IsWindows())
File.CreateSymbolicLink(Path.Combine(root, "docs", "26.1", "alias.md"),
Path.Combine(root, "docs", "26.1", "private.txt"));
var paths = new DocumentPathResolver(environment, versions);
Assert.Null(paths.ResolveRoute("26.1/private.txt", versions));
Assert.Null(paths.ResolveRoute("26.1/alias", versions));
}

[Theory]
[InlineData("../../../docs-private/secret.md")]
[InlineData("26.1/../../docs-private/secret.html")]
[InlineData("26.1/%2e%2e/%2e%2e/docs-private/secret.md")]
[InlineData("26.1/%2E%2E%2F%2E%2E%2Fdocs-private%2Fsecret.HTML")]
[InlineData("26.1/%252e%252e/%252e%252e/docs-private/secret.md")]
[InlineData("26.1/..%5c..%5cdocs-private%5csecret.md")]
[InlineData("26.1/..%255c..%255cdocs-private%255csecret.md")]
[InlineData("26.1/..;/..;/docs-private/secret.md")]
[InlineData("26.1/%c0%ae%c0%ae/%c0%ae%c0%ae/docs-private/secret.md")]
[InlineData("26.1/../../docs-private/secret.md%00.html")]
public async Task TraversalCannotRenderExistingFilesOutsideDocs(string route)
{
var outside = Path.Combine(root, "docs-private");
Directory.CreateDirectory(outside);
await File.WriteAllTextAsync(Path.Combine(outside, "secret.md"), "OUTSIDE_DOCS_SENTINEL");
var paths = new DocumentPathResolver(environment, versions);
var renderer = new DocRendererService(paths);
// Check raw, once-decoded and twice-decoded forms independently: none may
// reach the existing sibling file, regardless of upstream URL decoding.
for (var pass = 0; pass < 3; pass++)
{
var (provider, path) = DocumentPathResolver.ParseRoute(route, versions);
var error = await Record.ExceptionAsync(() => renderer.RenderToHtmlAsync(path, provider));
Assert.True(error is ArgumentException or FileNotFoundException,
$"Traversal was not rejected: {route}; exception: {error}");
route = Uri.UnescapeDataString(route);
}
var absoluteError = await Record.ExceptionAsync(() => renderer.RenderToHtmlAsync(
Path.Combine(outside, "secret.md"), versions.GetVersionProvider("26.1")));
Assert.True(absoluteError is ArgumentException or FileNotFoundException);
}

[Fact]
public void DoesNotFollowFileOrDirectoryLinksOutsideDocs()
{
if (OperatingSystem.IsWindows()) return;
var outside = Path.Combine(root, "docs-private");
Directory.CreateDirectory(outside);
File.WriteAllText(Path.Combine(outside, "secret.md"), "OUTSIDE_DOCS_SENTINEL");
File.CreateSymbolicLink(Path.Combine(root, "docs", "26.1", "external.md"),
Path.Combine(outside, "secret.md"));
Directory.CreateSymbolicLink(Path.Combine(root, "docs", "26.1", "external-folder"), outside);
var paths = new DocumentPathResolver(environment, versions);
Assert.Null(paths.ResolveRoute("26.1/external.HTML", versions));
Assert.Null(paths.ResolveRoute("26.1/external-folder/secret.HTML", versions));
}

private void Write(string path, string text)
{
var file = Path.Combine(root, "docs", path);
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllText(file, text);
}

public void Dispose() => Directory.Delete(root, true);

private sealed class TestEnvironment(string root) : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Tests";
public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = root;
public string WebRootPath { get; set; } = root;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
}
}
Loading
Loading