diff --git a/Generator.Tests/SolutionSelectionTests.cs b/Generator.Tests/SolutionSelectionTests.cs new file mode 100644 index 0000000..ba16001 --- /dev/null +++ b/Generator.Tests/SolutionSelectionTests.cs @@ -0,0 +1,75 @@ +using Generator.DTO; +using Generator.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Generator.Tests; + +public class SolutionSelectionTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t\r\n ")] + [InlineData(", ,,,\t")] + public async Task EmptySelectionIsRejectedBeforeDataverseAccess(string? value) + { + var config = Configuration(value); + var service = new SolutionService(null!, config, NullLogger.Instance); + + var error = await Assert.ThrowsAsync(() => service.GetSolutionIds()); + + Assert.Contains("DataverseSolutionNames", error.Message); + Assert.Contains("at least one solution unique name", error.Message); + } + + [Fact] + public void SelectionTrimsAndDeduplicatesNamesIgnoringCase() + { + Assert.Equal(new[] { "Example", "Other" }, SolutionSelection.Parse(" Example, ,example, Other,EXAMPLE,")); + } + + [Theory] + [InlineData("Typo", "Example", "Typo")] + [InlineData("Example,Typo", "Example", "Typo")] + [InlineData("First,Second", "", "First, Second")] + public void MissingSolutionsStopGenerationEvenWhenOtherNamesMatch(string requested, string matched, string missing) + { + var error = Assert.Throws(() => + SolutionSelection.ValidateMatches(SolutionSelection.Parse(requested), matched.Split(','))); + + Assert.Contains(missing, error.Message); + Assert.Contains("unique names, not display names", error.Message); + } + + [Fact] + public void MatchingIsCaseInsensitiveAndIndependentOfResultOrder() + { + SolutionSelection.ValidateMatches(new[] { "Example", "Other" }, new[] { "OTHER", "example" }); + } + + [Fact] + public void GeneratedCountUsesDistinctNonemptySolutionNames() + { + var output = Path.Combine(Path.GetTempPath(), "dmv-solutions-" + Guid.NewGuid()); + try + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["DataverseSolutionNames"] = " Example,example,, Other, ", + ["OutputFolder"] = output + }).Build(); + new WebsiteBuilder(config, [], [], new Dictionary()).AddData(); + + Assert.Contains("export const SolutionCount: number = 2;", File.ReadAllText(Path.Combine(output, "Data.ts"))); + } + finally + { + if (Directory.Exists(output)) Directory.Delete(output, recursive: true); + } + } + + private static IConfiguration Configuration(string? value) => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["DataverseSolutionNames"] = value }) + .Build(); +} diff --git a/Generator/Generator.csproj b/Generator/Generator.csproj index c467319..4e84a98 100644 --- a/Generator/Generator.csproj +++ b/Generator/Generator.csproj @@ -17,6 +17,10 @@ + + + + PreserveNewest diff --git a/Generator/Program.cs b/Generator/Program.cs index e494974..be5fdb2 100644 --- a/Generator/Program.cs +++ b/Generator/Program.cs @@ -15,6 +15,9 @@ .Build(); var verbose = configuration.GetValue("Verbosity", LogLevel.Warning); +// Reject an empty selection before creating a Dataverse client or requesting credentials. +SolutionSelection.Parse(configuration["DataverseSolutionNames"]); + // Set up dependency injection var services = new ServiceCollection(); diff --git a/Generator/Services/SolutionService.cs b/Generator/Services/SolutionService.cs index 475acec..860f96e 100644 --- a/Generator/Services/SolutionService.cs +++ b/Generator/Services/SolutionService.cs @@ -27,12 +27,7 @@ public SolutionService(ServiceClient client, IConfiguration configuration, ILogg /// public async Task<(List SolutionIds, List SolutionEntities)> GetSolutionIds() { - var solutionNameArg = configuration["DataverseSolutionNames"]; - if (solutionNameArg == null) - { - throw new Exception("Specify one or more solutions"); - } - var solutionNames = solutionNameArg.Split(",").Select(x => x.Trim().ToLower()).ToList(); + var solutionNames = SolutionSelection.Parse(configuration["DataverseSolutionNames"]); var entities = await client.RetrieveAllAsync(new QueryExpression("solution") { @@ -46,6 +41,8 @@ public SolutionService(ServiceClient client, IConfiguration configuration, ILogg } }); + SolutionSelection.ValidateMatches(solutionNames, entities.Select(e => e.GetAttributeValue("uniquename"))); + return (entities.Select(e => e.GetAttributeValue("solutionid")).ToList(), entities); } diff --git a/Generator/SolutionSelection.cs b/Generator/SolutionSelection.cs new file mode 100644 index 0000000..9af8def --- /dev/null +++ b/Generator/SolutionSelection.cs @@ -0,0 +1,31 @@ +namespace Generator; + +internal static class SolutionSelection +{ + public static string[] Parse(string? value) + { + var names = (value ?? string.Empty) + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (names.Length == 0) + { + throw new InvalidOperationException( + "DataverseSolutionNames must contain at least one solution unique name (not its display name)."); + } + + return names; + } + + public static void ValidateMatches(IEnumerable requestedNames, IEnumerable matchedNames) + { + var missing = requestedNames.Except(matchedNames, StringComparer.OrdinalIgnoreCase).ToArray(); + if (missing.Length > 0) + { + throw new InvalidOperationException( + $"DataverseSolutionNames contains solutions not found in the configured Dataverse environment: {string.Join(", ", missing)}. " + + "Use solution unique names, not display names. Metadata generation has been stopped."); + } + } +} diff --git a/Generator/WebsiteBuilder.cs b/Generator/WebsiteBuilder.cs index 4cbe5b4..92afbb8 100644 --- a/Generator/WebsiteBuilder.cs +++ b/Generator/WebsiteBuilder.cs @@ -41,7 +41,7 @@ internal void AddData() var logoUrl = configuration.GetValue("Logo", defaultValue: null); var jsValue = logoUrl != null ? $"\"{logoUrl}\"" : "null"; sb.AppendLine($"export const Logo: string | null = {jsValue};"); - sb.AppendLine($"export const SolutionCount: number = {configuration["DataverseSolutionNames"]?.Split(",").Length ?? -1};"); + sb.AppendLine($"export const SolutionCount: number = {SolutionSelection.Parse(configuration["DataverseSolutionNames"]).Length};"); sb.AppendLine(""); // ENTITIES diff --git a/README.md b/README.md index a5d1c63..599e05f 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The pipeline expects a variable group called `DataModel`. It must have the follo * AzureLocation: Name of the location for the resource group in Azure (e.g. "westeurope" - not the display name which is "West Europe"). * AzureResourceGroupName: Name of the Resource Group in Azure. If this matches an existing group in the location above that will be used for the DMV resources, if not a new resource group will be created. * DataverseUrl: URL for the Dataverse environment which the data model will be based on (e.g. "https://mySystem-dev.crm4.dynamics.com/"). -* DataverseSolutionNames: Comma-seperated list of solutions to based DMV on. Use the logical names (not display names). +* DataverseSolutionNames: Required comma-separated list of solution unique names (not display names). Whitespace, empty entries between commas, and duplicate names are ignored. Generation stops if no names remain or any requested solution cannot be found in the configured Dataverse environment. * WebsiteName: Used for the url of the web app presenting the data model to the user. The full URL will be in the format "https://wa-{WebsiteName}.azurewebsites.net/" and must be globally unique. * WebsitePassword: Password used by DMV users to login to the generated site. * WebsiteSessionSecret: Key to encrypt the session token with (You can set it to whatever you like, but recommended 32 random characters). @@ -167,3 +167,7 @@ openssl rand -base64 32 ## Running it Generate data by running the Generator project from Visual Studio. Afterwards go into the "Website"-folder from VS Code and open the terminal (of the "Command Prompt" type). If this the first time running it, type `npm install` (you need to have installed node.js first: https://nodejs.org/en/download/). Start the website on localhost by running `npm run dev`. Click the link in the terminal to view the website. + +To use a different local port, run `npm run dev -- --port 3017`. Login redirects use the request origin unless `AUTH_URL` or `NEXTAUTH_URL` overrides it (`AUTH_URL` takes precedence). If a redirect points to an old host or port, check these environment settings and restart the website after changing them. + +When hosting behind a reverse proxy or using a custom domain, set `AUTH_URL` to the public website origin, including its scheme and any non-default port. Do not use the internal application server address or rely on forwarded host headers alone. The supplied Azure infrastructure sets `NEXTAUTH_URL` to the generated App Service HTTPS address; update the auth URL setting when using a different public domain.