* 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>
163 lines
8.3 KiB
C#
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.");
|
|
}
|
|
}
|