using System.Reflection;
using Elsa.Authorization;
using Elsa.Permissions;
using Elsa.UserTasks.Permissions;
using Elsa.UserTasks.Services;
using FastEndpoints;
using NSubstitute;
namespace Elsa.UserTasks.UnitTests.Authorization;
///
/// Pins what each User Tasks endpoint requires, and checks that what they require is something the catalog
/// advertises.
///
///
/// The module reached production declaring access through the legacy string channel, which compared claims for
/// exact equality and appeared in no catalog. Nothing could see the gap: the coverage gate only asks whether an
/// endpoint declares something, and the migration guide's consistency check reads the guide, so a
/// permission absent from both the guide and the catalog was invisible from every direction. Asserting the
/// declarations against the descriptors closes that: an endpoint requiring a verb the module never advertises
/// cannot be granted through the role editor, and fails here rather than in a deployment.
///
public class EndpointPermissionTests
{
private static readonly Assembly Module = typeof(DefaultUserTaskAccessPolicy).Assembly;
/// What each endpoint is expected to require. The rows are the migration guide's table, in code.
private static readonly (string Endpoint, string Resource, string Verb)[] Expected =
[
("FeatureCapabilitiesEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.View),
("ListEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.View),
("GetEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.View),
("CapabilitiesEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.View),
("ListEventsEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.View),
("RevealFieldEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.View),
("ClaimEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Claim),
("ReleaseEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Claim),
("AssignEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Assign),
("ScheduleEndpoint", UserTasksResourcePermissions.UserTasks, CoreVerbs.Update),
("CompleteEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Complete),
("CancelEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Cancel),
("RetryResolutionEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Supervise),
("IssueInvitationEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Invite),
("ListInvitationsEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Invite),
("RevokeInvitationEndpoint", UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Invite),
("ListParticipantsEndpoint", UserTasksResourcePermissions.Participants, CoreVerbs.View)
];
public static TheoryData Declarations
{
get
{
var data = new TheoryData();
foreach (var (endpoint, resource, verb) in Expected)
data.Add(endpoint, resource, verb);
return data;
}
}
[Theory]
[MemberData(nameof(Declarations))]
public void EndpointDeclaresItsExpectedPermission(string endpointName, string resource, string verb)
{
var declared = Declare(endpointName);
Assert.Equal(new Permission(resource, verb), declared);
}
[Theory]
[MemberData(nameof(Declarations))]
public void EveryDeclaredPermissionIsAdvertisedByTheCatalog(string endpointName, string resource, string verb)
{
var declared = Declare(endpointName);
var descriptor = new UserTasksResourcePermissionsDescriptorProvider().GetDescriptors()
.SingleOrDefault(x => x.Resource == declared.Resource);
Assert.True(descriptor is not null, $"{endpointName} requires resource '{declared.Resource}', which the module contributes no descriptor for, so it cannot be granted through the role editor.");
Assert.True(descriptor!.Supports(declared.Verb), $"{endpointName} requires '{declared}', but '{declared.Resource}' advertises only [{string.Join(", ", descriptor.SupportedVerbs)}].");
// The theory data is what the migration guide's rows are written against, so it has to agree too.
Assert.Equal(new Permission(resource, verb), declared);
}
[Fact]
public void EveryEndpointInTheModuleIsCovered()
{
// Without this, deleting a row would silently stop testing an endpoint rather than fail.
var declaring = EndpointNames().OrderBy(x => x, StringComparer.Ordinal).ToArray();
var asserted = Expected.Select(x => x.Endpoint).OrderBy(x => x, StringComparer.Ordinal).ToArray();
Assert.Equal(declaring, asserted);
}
[Fact]
public void EveryAdvertisedVerbGuardsSomething()
{
// A verb nobody requires is one an administrator can grant to no effect.
var required = Expected.Select(x => new Permission(x.Resource, x.Verb)).ToHashSet();
var unused = new UserTasksResourcePermissionsDescriptorProvider().GetDescriptors()
.SelectMany(x => x.SupportedVerbs.Select(verb => new Permission(x.Resource, verb)))
.Where(x => !required.Contains(x))
.Select(x => x.ToString())
.OrderBy(x => x, StringComparer.Ordinal)
.ToArray();
Assert.True(unused.Length == 0, $"The catalog advertises {unused.Length} permission(s) no endpoint requires: {string.Join(", ", unused)}.");
}
/// The Elsa endpoints this module declares, by simple name.
private static IEnumerable EndpointNames() =>
Elsa.Testing.Shared.Authorization.EndpointCoverage.FindEndpoints(Module).Select(x => x.Name);
///
/// Runs one endpoint's Configure() and returns what it recorded. The requirement is attached as an
/// inline policy, which cannot be read back off the definition, so the registry is the only way to observe
/// a declaration without booting a host.
///
private static Permission Declare(string endpointName)
{
var endpointType = Module.GetTypes().Single(x => x.Name == endpointName);
var arguments = endpointType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Single()
.GetParameters()
.Select(x => Substitute.For([x.ParameterType], []))
.ToArray();
var endpoint = Activator.CreateInstance(endpointType, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, arguments, null)!;
var (requestType, responseType) = DtoTypes(endpointType);
endpointType.GetProperty("Definition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
.SetValue(endpoint, new EndpointDefinition(endpointType, requestType, responseType));
endpointType.GetMethod("Configure")!.Invoke(endpoint, null);
var permission = EndpointPermissionRegistry.Find(endpointType);
Assert.True(permission.HasValue, $"{endpointName} declares no permission.");
return permission!.Value;
}
private static (Type Request, Type Response) DtoTypes(Type endpointType)
{
for (var type = endpointType.BaseType; type is not null; type = type.BaseType)
{
if (!type.IsGenericType)
continue;
var definition = type.GetGenericTypeDefinition();
var arguments = type.GetGenericArguments();
if (definition == typeof(Elsa.Abstractions.ElsaEndpoint<,>))
return (arguments[0], arguments[1]);
if (definition == typeof(Elsa.Abstractions.ElsaEndpointWithoutRequest<>))
return (typeof(EmptyRequest), arguments[0]);
if (definition == typeof(Elsa.Abstractions.ElsaEndpoint<>))
return (arguments[0], typeof(object));
}
throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'.");
}
}