elsa-core/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs
Sipke Schoorstra 7ac2da7258
test(auth): check the upgrade guide's mapping table against the catalog (#7989)
* test(auth): check the upgrade guide's mapping table against the catalog

#7982 asked for the guide to be walked against a real deployment before
release notes point at it. Walking it once tells you about one afternoon, so
it is checked on every run instead.

The table tells operators how to rewrite every stored permission, and nothing
verified that what it tells them to write is a permission Elsa accepts. That
gap was not hypothetical: the cutover left several checks comparing against
legacy constants the guide itself instructs you to replace, so following it
silently disabled them. An 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.

The published document is read rather than a copy, so this fails when the
guide drifts from the code, which is the direction drift actually goes. Both
checks are guarded against passing vacuously: the parser asserts it found a
plausible number of rows, and the catalog asserts it is not empty.

Building that catalog took three attempts, and the first two under-reported
in ways worth recording. AppDomain.CurrentDomain.GetAssemblies() describes
whatever earlier tests happened to touch: this class saw 29 resources as
missing when run alone and none in a full run, which is an order-dependent
test and worse than no test. Walking GetReferencedAssemblies() from the two
hosts is no better, because the compiler drops references to assemblies whose
types the app never names -- AI, OpenTelemetry and Shells all vanished
despite being project references. Loading every Elsa.*.dll in the output
directory is complete and gives the same answer in isolation as in a full
run, which is the property that matters.

Verified by injecting a malformed entry and an unadvertised one: each check
fails with exactly that entry named, and nothing else.

The table as it stands passes both.

Refs #7982

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(auth): read every mapping row, whatever its indentation

Review found the row filter required an unindented "| `", so a formatting-only
change that indented the table 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.

Rows are now matched after trimming, and the left column no longer has to be
backticked. Each data row must yield at least one replacement, so a row that
parses to nothing fails instead of disappearing. Rows saying the permission
was *removed* rather than translated are recognised, and only those: ten of
them exist and none has a replacement to check.

Verified by indenting the whole table and breaking one entry. The old parser
would have read nothing and passed; this reports exactly the broken row.

Refs #7982

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 06:49:47 +02:00

163 lines
8.3 KiB
C#

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;
/// <summary>
/// Checks the upgrade guide's mapping table against the catalog a host actually builds.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
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<string, IReadOnlyCollection<string>> catalog, Permission permission) =>
catalog.TryGetValue(permission.Resource, out var verbs) && verbs.Contains(permission.Verb, StringComparer.Ordinal);
/// <summary>The resources and verbs every Elsa module shipped alongside these tests contributes.</summary>
/// <remarks>
/// Built by loading every <c>Elsa.*.dll</c> in the output directory, which is deterministic and complete.
/// Two lazier approaches were tried and both under-report. <c>AppDomain.CurrentDomain.GetAssemblies()</c>
/// 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 <c>GetReferencedAssemblies()</c> 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.
/// </remarks>
private static Dictionary<string, IReadOnlyCollection<string>> 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<string>)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<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(x => x is not null)!;
}
}
/// <summary>Every permission the mapping table's right-hand column tells an operator to write.</summary>
/// <remarks>
/// Rows are matched after trimming, and every data row must yield at least one replacement. Requiring a
/// row to start with an unindented <c>| `</c> 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.
/// </remarks>
private static IReadOnlyCollection<string> 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<string>();
var emptyRows = new List<string>();
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.");
}
}