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.");
}
}