diff --git a/actions/docs-verifier/MSDocsBuildVerifier.sln b/actions/docs-verifier/MSDocsBuildVerifier.sln index ad8db2a0..7c0d8899 100644 --- a/actions/docs-verifier/MSDocsBuildVerifier.sln +++ b/actions/docs-verifier/MSDocsBuildVerifier.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.33402.96 +# Visual Studio Version 18 +VisualStudioVersion = 18.9.12128.139 oobstable MinimumVisualStudioVersion = 15.0.26124.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FB9E2FB6-DF28-4985-87D6-0334B8260365}" ProjectSection(SolutionItems) = preProject @@ -28,6 +28,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ActionRunner", "src\ActionR EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildVerifier.IO.Abstractions", "src\BuildVerifier.IO.Abstractions\BuildVerifier.IO.Abstractions.csproj", "{FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocfxVerifier", "src\DocfxVerifier\DocfxVerifier.csproj", "{3E0FB4E3-3080-D891-0CB6-382B700A7AA8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -122,6 +124,18 @@ Global {FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}.Release|x64.Build.0 = Release|Any CPU {FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}.Release|x86.ActiveCfg = Release|Any CPU {FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}.Release|x86.Build.0 = Release|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x64.Build.0 = Debug|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x86.Build.0 = Debug|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|Any CPU.Build.0 = Release|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x64.ActiveCfg = Release|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x64.Build.0 = Release|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x86.ActiveCfg = Release|Any CPU + {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj b/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj index 551ad3db..935fa217 100644 --- a/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj +++ b/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj @@ -8,6 +8,7 @@ + diff --git a/actions/docs-verifier/src/ActionRunner/Program.cs b/actions/docs-verifier/src/ActionRunner/Program.cs index bd966d0d..316e6763 100644 --- a/actions/docs-verifier/src/ActionRunner/Program.cs +++ b/actions/docs-verifier/src/ActionRunner/Program.cs @@ -106,6 +106,37 @@ IEnumerable matchers = await docfxConfigurationReader.MapConfigurationAsync(); IEnumerable pullRequestFiles = await GitHubPullRequest.GetPullRequestFilesAsync(pullRequestNumber); +IEnumerable modifiedDocfxFiles = pullRequestFiles + .Where(file => !file.IsRemoved() && IsDocfxJsonPath(file.FileName)) + .Select(file => file.FileName) + .Distinct(StringComparer.OrdinalIgnoreCase); + +foreach (string docfxFilePath in modifiedDocfxFiles) +{ + if (!await DocfxVerifier.PathVerifier.WriteResultsAsync(Console.Out, docfxFilePath)) + { + returnCode++; + } +} + +// Verify that all redirection URLs in modified +// redirection files are valid and reachable. +HashSet redirectionFileSet = + new(redirectionFiles.Select(NormalizePath), StringComparer.OrdinalIgnoreCase); + +IEnumerable modifiedRedirectionFiles = pullRequestFiles + .Where(file => !file.IsRemoved() && IsRegisteredRedirectionFile(file.FileName, redirectionFileSet)) + .Select(file => file.FileName) + .Distinct(StringComparer.OrdinalIgnoreCase); + +foreach (string redirectionFilePath in modifiedRedirectionFiles) +{ + if (!await RedirectTargetVerifier.WriteResultsAsync(Console.Out, redirectionFilePath)) + { + returnCode++; + } +} + List files = [.. pullRequestFiles.Where(f => IsRedirectableFile(f, matchers))]; @@ -132,8 +163,7 @@ return returnCode; -static bool IsRedirectableFile( - PullRequestFile file, IEnumerable matchers) +static bool IsRedirectableFile(PullRequestFile file, IEnumerable matchers) { string? deletedFileName = file.IsRenamed() ? file.PreviousFileName @@ -144,15 +174,32 @@ static bool IsRedirectableFile( // A deleted toc.yml doesn't need redirection. // Also, don't require a redirection for file patterns specified as "exclude"s in docfx config file. - return !isDeletedToc && IsYmlOrMarkdownFile(deletedFileName) + return !isDeletedToc + && IsYmlOrMarkdownFile(deletedFileName) && matchers.Any(m => m.Match(deletedFileName).HasMatches); } static bool IsYmlOrMarkdownFile([NotNullWhen(true)] string? fileName) => Path.GetExtension(fileName) is ".yml" or ".md"; +static bool IsDocfxJsonPath(string? path) +{ + if (path is null) + { + return false; + } + + string normalized = path.Replace('\\', '/'); + return normalized.Equals("docfx.json", StringComparison.OrdinalIgnoreCase) + || normalized.EndsWith("/docfx.json", StringComparison.OrdinalIgnoreCase); +} + +static bool IsRegisteredRedirectionFile(string? path, HashSet redirectionFilesSet) => + path is not null && redirectionFilesSet.Contains(NormalizePath(path)); + +static string NormalizePath(string path) => path.Replace('\\', '/'); + static bool IsExtensionChangeOnly(string file1, string file2) => RemoveExtension(file1).Equals(RemoveExtension(file2), StringComparison.OrdinalIgnoreCase); -static string RemoveExtension(string file) => - file.Substring(0, file.Length - Path.GetExtension(file).Length); +static string RemoveExtension(string file) => file[..^Path.GetExtension(file).Length]; diff --git a/actions/docs-verifier/src/DocfxVerifier/DocfxVerifier.csproj b/actions/docs-verifier/src/DocfxVerifier/DocfxVerifier.csproj new file mode 100644 index 00000000..b7601447 --- /dev/null +++ b/actions/docs-verifier/src/DocfxVerifier/DocfxVerifier.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/actions/docs-verifier/src/DocfxVerifier/PathVerifier.cs b/actions/docs-verifier/src/DocfxVerifier/PathVerifier.cs new file mode 100644 index 00000000..7d0ace4d --- /dev/null +++ b/actions/docs-verifier/src/DocfxVerifier/PathVerifier.cs @@ -0,0 +1,208 @@ +using System.Text.Json; + +namespace DocfxVerifier +{ + /// + /// Validates file path entries declared + /// under build.fileMetadata in a docfx.json file. + /// + public static class PathVerifier + { + private static readonly JsonDocumentOptions s_jsonDocumentOptions = new() + { + AllowTrailingCommas = true + }; + + /// + /// Verifies that file paths in the docfx.json file are valid. + /// + public static Task WriteResultsAsync(TextWriter writer) => + WriteResultsAsync(writer, configurationPath: null); + + /// + /// Verifies that file paths in a specific docfx.json file are valid. + /// + public static async Task WriteResultsAsync(TextWriter writer, string? configurationPath) + { + ArgumentNullException.ThrowIfNull(writer, nameof(writer)); + + configurationPath ??= FindDocfxConfigurationPath(); + if (configurationPath is null) + { + await writer.WriteLineAsync("::error::Unable to find docfx.json in the repository root or its immediate subdirectories."); + return false; + } + + if (!File.Exists(configurationPath)) + { + await writer.WriteLineAsync($"::error::docfx.json file '{configurationPath}' does not exist."); + return false; + } + + using FileStream stream = File.OpenRead(configurationPath); + using JsonDocument json = await JsonDocument.ParseAsync(stream, s_jsonDocumentOptions); + + string repositoryRoot = Directory.GetCurrentDirectory(); + string configurationDirectory = Path.GetDirectoryName(Path.GetFullPath(configurationPath)) ?? repositoryRoot; + string configurationPathForLog = configurationPath.Replace('\\', '/'); + + var errors = new List(); + ValidateFileMetadataPaths(json.RootElement, repositoryRoot, configurationDirectory, errors); + + foreach (string error in errors) + { + await writer.WriteLineAsync($"::error file={configurationPathForLog}::{error}"); + } + + return errors.Count == 0; + } + + private static void ValidateFileMetadataPaths( + JsonElement element, + string repositoryRoot, + string configurationDirectory, + List errors) + { + if (element.ValueKind != JsonValueKind.Object) + { + return; + } + + if (element.TryGetProperty("build", out JsonElement buildSection) + && buildSection.ValueKind == JsonValueKind.Object) + { + ValidateBuildFileMetadataSection(buildSection, "$.build", repositoryRoot, configurationDirectory, errors); + } + } + + private static void ValidateBuildFileMetadataSection( + JsonElement buildSection, + string jsonPath, + string repositoryRoot, + string configurationDirectory, + List errors) + { + if (buildSection.TryGetProperty("fileMetadata", out JsonElement fileMetadata) + && fileMetadata.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty metadataProperty in fileMetadata.EnumerateObject()) + { + if (metadataProperty.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + foreach (JsonProperty pathProperty in metadataProperty.Value.EnumerateObject()) + { + ValidatePath( + pathProperty.Name, + $"{jsonPath}.fileMetadata.{metadataProperty.Name}.{pathProperty.Name}", + repositoryRoot, + configurationDirectory, + errors); + } + } + } + } + + private static void ValidatePath( + string? path, + string jsonPath, + string repositoryRoot, + string resolutionBaseDirectory, + List errors) + { + if (string.IsNullOrWhiteSpace(path) || path is ".") + { + return; + } + + if (Uri.TryCreate(path, UriKind.Absolute, out Uri? uri) + && uri is not null + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return; + } + + string normalizedPath = path.Replace('\\', '/'); + string nonWildcardPrefix = GetNonWildcardPrefix(normalizedPath); + if (string.IsNullOrEmpty(nonWildcardPrefix)) + { + return; + } + + if (!ExistsInRepository(nonWildcardPrefix, repositoryRoot, resolutionBaseDirectory)) + { + errors.Add($"{jsonPath}: Path '{path}' is invalid."); + } + } + + private static bool ExistsInRepository(string path, string repositoryRoot, string resolutionBaseDirectory) + { + string? combinedPath = TryResolvePathWithinRepository(path, repositoryRoot, resolutionBaseDirectory); + return combinedPath is not null && (File.Exists(combinedPath) || Directory.Exists(combinedPath)); + } + + private static string? TryResolvePathWithinRepository( + string? path, + string repositoryRoot, + string resolutionBaseDirectory) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + string combinedPath = Path.GetFullPath(Path.Combine(resolutionBaseDirectory, path)); + string relative = Path.GetRelativePath(Path.GetFullPath(repositoryRoot), combinedPath); + + if (relative.Equals("..", StringComparison.Ordinal) + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + return null; + } + + return combinedPath; + } + + private static string GetNonWildcardPrefix(string path) + { + ReadOnlySpan wildcardChars = ['*', '?', '[', ']', '{', '}']; + string[] segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + + var prefixSegments = new List(); + foreach (string segment in segments) + { + if (segment.AsSpan().IndexOfAny(wildcardChars) >= 0) + { + break; + } + + prefixSegments.Add(segment); + } + + return string.Join('/', prefixSegments); + } + + private static string? FindDocfxConfigurationPath() + { + const string fileName = "docfx.json"; + if (File.Exists(fileName)) + { + return fileName; + } + + foreach (string directory in Directory.GetDirectories(".", "*", SearchOption.TopDirectoryOnly)) + { + string candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + } +} diff --git a/actions/docs-verifier/src/RedirectionVerifier/Properties/AssemblyInfo.cs b/actions/docs-verifier/src/RedirectionVerifier/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..542c99c6 --- /dev/null +++ b/actions/docs-verifier/src/RedirectionVerifier/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GitHub.UnitTests")] diff --git a/actions/docs-verifier/src/RedirectionVerifier/RedirectTargetVerifier.cs b/actions/docs-verifier/src/RedirectionVerifier/RedirectTargetVerifier.cs new file mode 100644 index 00000000..5d542787 --- /dev/null +++ b/actions/docs-verifier/src/RedirectionVerifier/RedirectTargetVerifier.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using System.Net; + +namespace RedirectionVerifier; + +public static class RedirectTargetVerifier +{ + private const string LearnMicrosoftCom = "https://learn.microsoft.com"; + private static readonly HttpClient s_httpClient = new() + { + Timeout = TimeSpan.FromSeconds(15) + }; + + /// + /// Verifies that redirect targets in an entire redirection file are valid. + /// + public static async Task WriteResultsAsync( + TextWriter writer, + string redirectionFilePath) + => await WriteResultsAsync(writer, redirectionFilePath, GetStatusCodeAsync); + + internal static async Task WriteResultsAsync( + TextWriter writer, + string redirectionFilePath, + Func> statusCodeProvider) + { + ArgumentNullException.ThrowIfNull(writer, nameof(writer)); + ArgumentNullException.ThrowIfNull(redirectionFilePath, nameof(redirectionFilePath)); + ArgumentNullException.ThrowIfNull(statusCodeProvider, nameof(statusCodeProvider)); + + if (!File.Exists(redirectionFilePath)) + { + await writer.WriteLineAsync($"::error::Redirection file '{redirectionFilePath}' does not exist."); + return false; + } + + OpenPublishingRedirectionReader reader = new(redirectionFilePath); + ImmutableArray redirections = await reader.MapConfigurationAsync(); + if (redirections.IsDefaultOrEmpty) + { + return true; + } + + bool isValid = true; + for (int i = 0; i < redirections.Length; i++) + { + string? redirectUrl = redirections[i].RedirectUrl; + if (string.IsNullOrWhiteSpace(redirectUrl)) + { + await writer.WriteLineAsync($"::error file={redirectionFilePath}::Redirection at index {i} has an empty 'redirect_url'."); + isValid = false; + continue; + } + + if (redirectUrl[0] != '/') + { + continue; + } + + string redirectTarget = $"{LearnMicrosoftCom}{redirectUrl}"; + + bool hasValidUri = Uri.TryCreate(redirectTarget, UriKind.Absolute, out Uri? uri) + && uri is not null + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + if (!hasValidUri) + { + await writer.WriteLineAsync($"::error file={redirectionFilePath}::Invalid 'redirect_url' at index {i}: '{redirectUrl}'."); + isValid = false; + continue; + } + + HttpStatusCode? statusCode = await statusCodeProvider(uri!); + if (statusCode is null) + { + await writer.WriteLineAsync($"::error file={redirectionFilePath}::Unable to verify 'redirect_url' at index {i}: '{redirectUrl}'."); + isValid = false; + continue; + } + + if (statusCode == HttpStatusCode.NotFound) + { + await writer.WriteLineAsync($"::error file={redirectionFilePath}::Redirect target returns 404 at index {i}: '{redirectUrl}'."); + isValid = false; + } + } + + return isValid; + } + + private static async Task GetStatusCodeAsync(Uri uri) + { + try + { + using HttpRequestMessage headRequest = new(HttpMethod.Head, uri); + using HttpResponseMessage headResponse = await s_httpClient.SendAsync(headRequest); + if (headResponse.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented or HttpStatusCode.NotFound) + { + using HttpRequestMessage getRequest = new(HttpMethod.Get, uri); + using HttpResponseMessage getResponse = await s_httpClient.SendAsync(getRequest, HttpCompletionOption.ResponseHeadersRead); + return getResponse.StatusCode; + } + + return headResponse.StatusCode; + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + } +} diff --git a/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj b/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj index 14fc2bce..74c7e150 100644 --- a/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj +++ b/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj @@ -19,6 +19,8 @@ + + - \ No newline at end of file + diff --git a/actions/docs-verifier/tests/GitHub.UnitTests/PathVerifierTests.cs b/actions/docs-verifier/tests/GitHub.UnitTests/PathVerifierTests.cs new file mode 100644 index 00000000..3baaa173 --- /dev/null +++ b/actions/docs-verifier/tests/GitHub.UnitTests/PathVerifierTests.cs @@ -0,0 +1,262 @@ +using DocfxVerifier; +using Xunit; + +namespace GitHub.UnitTests; + +public class PathVerifierTests +{ + private static readonly SemaphoreSlim s_currentDirectoryLock = new(1, 1); + + [Fact] + public async Task WriteResultsAsyncReturnsTrueForValidFileMetadataPaths() + { + await s_currentDirectoryLock.WaitAsync(); + string testRoot = CreateTempDirectory(); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(testRoot); + Directory.CreateDirectory(Path.Combine("docs", "valid")); + await File.WriteAllTextAsync("docfx.json", """ + { + "build": { + "fileMetadata": { + "ms.author": { + "docs/valid/**/**.{md,yml}": "someone" + } + } + } + } + """); + + using var writer = new StringWriter(); + bool result = await PathVerifier.WriteResultsAsync(writer); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(testRoot, recursive: true); + s_currentDirectoryLock.Release(); + } + } + + [Fact] + public async Task WriteResultsAsyncReturnsFalseForInvalidFileMetadataPaths() + { + await s_currentDirectoryLock.WaitAsync(); + string testRoot = CreateTempDirectory(); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(testRoot); + await File.WriteAllTextAsync("docfx.json", """ + { + "build": { + "fileMetadata": { + "ms.author": { + "missing/path/**/**.{md,yml}": "someone" + } + } + } + } + """); + + using var writer = new StringWriter(); + bool result = await PathVerifier.WriteResultsAsync(writer); + string output = writer.ToString(); + + Assert.False(result); + Assert.Contains("Path 'missing/path/**/**.{md,yml}' is invalid", output, StringComparison.Ordinal); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(testRoot, recursive: true); + s_currentDirectoryLock.Release(); + } + } + + [Fact] + public async Task WriteResultsAsyncIgnoresNonFileMetadataPathEntries() + { + await s_currentDirectoryLock.WaitAsync(); + string testRoot = CreateTempDirectory(); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(testRoot); + await File.WriteAllTextAsync("docfx.json", """ + { + "build": { + "content": [ + { + "files": ["**/*.md"], + "src": "missing-folder" + } + ], + "template": ["missing-template"], + "fileMetadata": { + "ms.author": { + "**/*.md": "someone" + } + } + }, + "globalMetadata": { + "src": "not-a-docfx-path" + } + } + """); + + using var writer = new StringWriter(); + bool result = await PathVerifier.WriteResultsAsync(writer); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(testRoot, recursive: true); + s_currentDirectoryLock.Release(); + } + } + + [Fact] + public async Task WriteResultsAsyncFindsDocfxInSubdirectory() + { + await s_currentDirectoryLock.WaitAsync(); + string testRoot = CreateTempDirectory(); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(testRoot); + Directory.CreateDirectory(Path.Combine("docs", "content", "guides")); + await File.WriteAllTextAsync(Path.Combine("docs", "docfx.json"), """ + { + "build": { + "fileMetadata": { + "ms.topic": { + "content/**": "conceptual" + } + } + } + } + """); + + using var writer = new StringWriter(); + bool result = await PathVerifier.WriteResultsAsync(writer); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(testRoot, recursive: true); + s_currentDirectoryLock.Release(); + } + } + + [Fact] + public async Task WriteResultsAsyncUsesSpecifiedDocfxPath() + { + await s_currentDirectoryLock.WaitAsync(); + string testRoot = CreateTempDirectory(); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(testRoot); + + await File.WriteAllTextAsync("docfx.json", """ + { + "build": { + "fileMetadata": { + "ms.author": { + "missing-root-folder/**": "someone" + } + } + } + } + """); + + Directory.CreateDirectory(Path.Combine("valid-docs", "valid")); + string modifiedDocfxPath = Path.Combine("valid-docs", "docfx.json"); + await File.WriteAllTextAsync(modifiedDocfxPath, """ + { + "build": { + "fileMetadata": { + "ms.author": { + "valid/**": "someone" + } + } + } + } + """); + + using var writer = new StringWriter(); + bool result = await PathVerifier.WriteResultsAsync(writer, modifiedDocfxPath); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(testRoot, recursive: true); + s_currentDirectoryLock.Release(); + } + } + + [Fact] + public async Task WriteResultsAsyncAllowsTrailingCommas() + { + await s_currentDirectoryLock.WaitAsync(); + string testRoot = CreateTempDirectory(); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(testRoot); + Directory.CreateDirectory("docs"); + + await File.WriteAllTextAsync("docfx.json", """ + { + "build": { + "fileMetadata": { + "ms.author": { + "docs/**": "someone", + }, + }, + }, + } + """); + + using var writer = new StringWriter(); + bool result = await PathVerifier.WriteResultsAsync(writer); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(testRoot, recursive: true); + s_currentDirectoryLock.Release(); + } + } + + private static string CreateTempDirectory() + { + string path = Path.Combine(Path.GetTempPath(), $"path-verifier-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/actions/docs-verifier/tests/GitHub.UnitTests/RedirectTargetVerifierTests.cs b/actions/docs-verifier/tests/GitHub.UnitTests/RedirectTargetVerifierTests.cs new file mode 100644 index 00000000..99a611ef --- /dev/null +++ b/actions/docs-verifier/tests/GitHub.UnitTests/RedirectTargetVerifierTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using RedirectionVerifier; +using Xunit; + +namespace GitHub.UnitTests; + +public class RedirectTargetVerifierTests +{ + [Fact] + public async Task WriteResultsAsyncReturnsTrueForValidLearnUrlPath() + { + string redirectionFilePath = await CreateRedirectionFileAsync("/dotnet"); + try + { + using var writer = new StringWriter(); + bool result = await RedirectTargetVerifier.WriteResultsAsync( + writer, + redirectionFilePath, + _ => Task.FromResult(HttpStatusCode.OK)); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + } + finally + { + File.Delete(redirectionFilePath); + } + } + + [Fact] + public async Task WriteResultsAsyncSkipsNonLearnUrlTargets() + { + string redirectionFilePath = await CreateRedirectionFileAsync("not-a-valid-url"); + bool statusProviderCalled = false; + + try + { + using var writer = new StringWriter(); + bool result = await RedirectTargetVerifier.WriteResultsAsync( + writer, + redirectionFilePath, + _ => + { + statusProviderCalled = true; + return Task.FromResult(HttpStatusCode.OK); + }); + + Assert.True(result); + Assert.Equal(string.Empty, writer.ToString()); + Assert.False(statusProviderCalled); + } + finally + { + File.Delete(redirectionFilePath); + } + } + + [Fact] + public async Task WriteResultsAsyncReturnsFalseFor404Url() + { + string redirectionFilePath = await CreateRedirectionFileAsync("/missing"); + try + { + using var writer = new StringWriter(); + bool result = await RedirectTargetVerifier.WriteResultsAsync( + writer, + redirectionFilePath, + _ => Task.FromResult(HttpStatusCode.NotFound)); + + Assert.False(result); + Assert.Contains("returns 404", writer.ToString(), StringComparison.Ordinal); + } + finally + { + File.Delete(redirectionFilePath); + } + } + + [Fact] + public async Task WriteResultsAsyncReturnsFalseWhenLearnUrlCannotBeVerified() + { + string redirectionFilePath = await CreateRedirectionFileAsync("/dotnet"); + + try + { + using var writer = new StringWriter(); + bool result = await RedirectTargetVerifier.WriteResultsAsync( + writer, + redirectionFilePath, + _ => Task.FromResult(null)); + + Assert.False(result); + Assert.Contains("Unable to verify 'redirect_url'", writer.ToString(), StringComparison.Ordinal); + } + finally + { + File.Delete(redirectionFilePath); + } + } + + private static async Task CreateRedirectionFileAsync(string redirectUrl) + { + string filePath = Path.Combine(Path.GetTempPath(), $"redirect-{Guid.NewGuid():N}.json"); + string content = $$""" + { + "redirections": [ + { + "source_path": "docs/old.md", + "redirect_url": "{{redirectUrl}}" + } + ] + } + """; + + await File.WriteAllTextAsync(filePath, content); + return filePath; + } + +}