diff --git a/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs b/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs new file mode 100644 index 000000000..5d95ddf5a --- /dev/null +++ b/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs @@ -0,0 +1,162 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Elsa.Authorization; +using Elsa.Permissions; +using Elsa.ModularServer.Web; +using Elsa.Server.Web; + +namespace Elsa.Hosts.SmokeTests; + +/// +/// Checks the upgrade guide's mapping table against the catalog a host actually builds. +/// +/// +/// The table tells operators how to rewrite every stored permission, and nothing had ever checked that what +/// it tells them to write is a permission Elsa accepts. That is not hypothetical: the cutover left several +/// checks comparing against legacy constants which the guide itself instructs you to replace, so following +/// it silently disabled them. A table entry that does not parse, or that names a resource or verb no module +/// advertises, is a deployment locked out of an endpoint by doing exactly as it was told. +/// +/// Reading the published document rather than a copy is the point: this fails when the guide drifts from the +/// code, which is the direction the drift actually goes. +/// +/// +public class MigrationGuideTests +{ + [Fact] + public void EveryReplacementInTheMappingTableIsAWellFormedPermission() + { + var malformed = ReadReplacements() + .Where(x => !Permission.TryParse(x, out _)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + Assert.True(malformed.Length == 0, $"The guide tells operators to write {malformed.Length} value(s) that are not well-formed permissions, so nobody can hold them: {string.Join(", ", malformed)}."); + } + + [Fact] + public void EveryReplacementInTheMappingTableIsAdvertisedByTheCatalog() + { + var catalog = BuildCatalog(); + + // A catalog that came up empty would pass every check below without testing anything. + Assert.True(catalog.Count > 20, $"Only {catalog.Count} resource(s) were discovered; the catalog is not being built and this test would pass vacuously."); + + var unadvertised = ReadReplacements() + .Where(x => Permission.TryParse(x, out var permission) && !permission.HasWildcard && !IsAdvertised(catalog, permission)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + Assert.True(unadvertised.Length == 0, $"{unadvertised.Length} replacement(s) name a resource or verb no module advertises, so a role rewritten as the guide says would not authorize anything: {string.Join(", ", unadvertised)}."); + } + + private static bool IsAdvertised(IReadOnlyDictionary> catalog, Permission permission) => + catalog.TryGetValue(permission.Resource, out var verbs) && verbs.Contains(permission.Verb, StringComparer.Ordinal); + + /// The resources and verbs every Elsa module shipped alongside these tests contributes. + /// + /// Built by loading every Elsa.*.dll in the output directory, which is deterministic and complete. + /// Two lazier approaches were tried and both under-report. AppDomain.CurrentDomain.GetAssemblies() + /// describes whatever earlier tests happened to touch, so this class saw 29 resources as missing when run + /// alone and none in a full run. Walking GetReferencedAssemblies() from the two hosts is no better: + /// the compiler drops references to assemblies whose types the app never names, so AI, OpenTelemetry and + /// Shells vanished despite being project references. The output directory has every module either host + /// pulls in, regardless of whether its types are mentioned. + /// + private static Dictionary> BuildCatalog() => + Directory.GetFiles(AppContext.BaseDirectory, "Elsa.*.dll") + .Select(TryLoad) + .Where(x => x is not null) + .SelectMany(x => SafeGetTypes(x!)) + .Where(x => x is { IsClass: true, IsAbstract: false } && typeof(IPermissionDescriptorProvider).IsAssignableFrom(x) && x.GetConstructor(Type.EmptyTypes) is not null) + .Select(x => (IPermissionDescriptorProvider)Activator.CreateInstance(x)!) + .SelectMany(x => x.GetDescriptors()) + .GroupBy(x => x.Resource, StringComparer.Ordinal) + .ToDictionary(x => x.Key, x => (IReadOnlyCollection)x.SelectMany(d => d.SupportedVerbs).Distinct(StringComparer.Ordinal).ToArray(), StringComparer.Ordinal); + + private static Assembly? TryLoad(string path) + { + try + { + return Assembly.LoadFrom(path); + } + catch (Exception ex) when (ex is BadImageFormatException or FileLoadException or FileNotFoundException) + { + // A native or otherwise unloadable file in the output directory contributes nothing. + return null; + } + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(x => x is not null)!; + } + } + + /// Every permission the mapping table's right-hand column tells an operator to write. + /// + /// Rows are matched after trimming, and every data row must yield at least one replacement. Requiring a + /// row to start with an unindented | ` looked equivalent and was not: a formatting-only change that + /// indented the table, or a left column written without backticks, would drop rows silently while the + /// totals still looked plausible. A skipped row is an unchecked permission, which is the one outcome this + /// test exists to prevent, so it is made loud rather than merely unlikely. + /// + private static IReadOnlyCollection ReadReplacements() + { + var guide = FindGuide(); + var lines = File.ReadAllLines(guide); + var start = Array.FindIndex(lines, x => x.Trim().StartsWith("## Full mapping", StringComparison.Ordinal)); + Assert.True(start >= 0, $"No '## Full mapping' section in {guide}. If the section was renamed, this test is looking in the wrong place rather than passing vacuously."); + + var rows = lines.Skip(start + 1) + .TakeWhile(x => !x.Trim().StartsWith("## ", StringComparison.Ordinal)) + .Select(x => x.Trim()) + .Where(x => x.StartsWith('|')) + .Select(x => x.Split('|')) + .Where(x => x.Length >= 3) + // The header names the columns and the next row is the --- separator; neither maps a permission. + .Where(x => !x[1].Trim().Trim('-', ':', ' ').Equals(string.Empty, StringComparison.Ordinal)) + .Where(x => x[1].Contains('`')) + .ToArray(); + + var replacements = new List(); + var emptyRows = new List(); + + foreach (var row in rows) + { + var matches = Regex.Matches(row[2], "`([^`]+)`").Select(x => x.Groups[1].Value).ToArray(); + + if (matches.Length > 0) + replacements.AddRange(matches); + // A permission that was dropped rather than translated has no replacement to check. Those rows say + // so in words, so they are recognised rather than treated as a parse failure -- but only those. + else if (!row[2].Contains("removed", StringComparison.OrdinalIgnoreCase)) + emptyRows.Add(row[1].Trim()); + } + + Assert.True(emptyRows.Count == 0, $"{emptyRows.Count} mapping row(s) yielded no replacement and do not say the permission was removed, so what they document is going unchecked: {string.Join(", ", emptyRows)}."); + + // A parser that silently matches nothing passes forever. + Assert.True(rows.Length > 20, $"Only {rows.Length} mapping row(s) parsed out of the table; the format has changed and this test is no longer reading it."); + return replacements.Distinct(StringComparer.Ordinal).ToArray(); + } + + private static string FindGuide() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, "doc", "migrations", "authorization-model.md"); + + if (File.Exists(candidate)) + return candidate; + } + + throw new FileNotFoundException("Could not locate doc/migrations/authorization-model.md from the test output directory."); + } +}