Unit tests for the permission system. Exercises PolicySnapshot, GroupPolicy, UserPolicy, PermissionResolver and related behavior such as inheritance, registration, caching, validation and default policy rules.
using Xunit;
namespace SboxSuperuser.Contracts.Tests;
public sealed class PermissionTests
{
private const string User = "76561198000000001";
private const string Key = "admin.players.kick";
private static PermissionDescriptor[] Registered => new[] { new PermissionDescriptor( Key, "Kick" ) };
private static PermissionEntry Entry( PermissionEffect effect ) => new( Key, effect );
private static PolicySnapshot Snapshot( long revision, PermissionEffect direct, bool allow, bool deny, bool reverse = false )
{
var parents = new[] { "allow", "deny" };
if ( reverse ) Array.Reverse( parents );
return new PolicySnapshot( revision, new[]
{
new GroupPolicy( "player" ),
new GroupPolicy( "allow", permissions: allow ? new[] { Entry( PermissionEffect.Allow ) } : null ),
new GroupPolicy( "deny", permissions: deny ? new[] { Entry( PermissionEffect.Deny ) } : null ),
new GroupPolicy( "staff", parents )
}, new[] { new UserPolicy( User, new[] { "staff" }, new[] { Entry( direct ) } ) } );
}
[Theory]
[InlineData( PermissionEffect.Unset, false, false, false )]
[InlineData( PermissionEffect.Unset, true, false, true )]
[InlineData( PermissionEffect.Unset, false, true, false )]
[InlineData( PermissionEffect.Unset, true, true, false )]
[InlineData( PermissionEffect.Allow, false, false, true )]
[InlineData( PermissionEffect.Allow, true, false, true )]
[InlineData( PermissionEffect.Allow, false, true, true )]
[InlineData( PermissionEffect.Allow, true, true, true )]
[InlineData( PermissionEffect.Deny, false, false, false )]
[InlineData( PermissionEffect.Deny, true, false, false )]
[InlineData( PermissionEffect.Deny, false, true, false )]
[InlineData( PermissionEffect.Deny, true, true, false )]
public void ConflictRulesAreIndependentOfParentOrdering( PermissionEffect direct, bool allow, bool deny, bool expected )
{
foreach ( var reverse in new[] { false, true } )
{
var result = new PermissionResolver( Snapshot( 7, direct, allow, deny, reverse ), Registered ).Explain( User, Key );
Assert.Equal( expected, result.IsAllowed );
Assert.Equal( direct, result.DirectEntry );
Assert.Equal( 7, result.PolicyRevision );
Assert.True( result.IsRegistered );
Assert.False( result.OwnerBypass );
Assert.Equal( (allow ? 1 : 0) + (deny ? 1 : 0), result.Groups.Count );
if ( deny ) Assert.Contains( result.Groups, group => group.GroupId == "deny" && group.Effect == PermissionEffect.Deny );
Assert.Equal( result.Groups.OrderBy( group => group.GroupId, StringComparer.Ordinal ).Select( group => group.GroupId ),
result.Groups.Select( group => group.GroupId ) );
}
}
[Fact]
public void UnknownPermissionDeniesEvenExplicitDirectAllowAndExplainsWhy()
{
var resolver = new PermissionResolver( Snapshot( 1, PermissionEffect.Allow, true, false ), Array.Empty<PermissionDescriptor>() );
var result = resolver.Explain( User, Key );
Assert.False( result.IsAllowed );
Assert.False( result.IsRegistered );
Assert.Contains( "not registered", result.Reason );
Assert.Equal( PermissionEffect.Allow, result.DirectEntry );
}
[Fact]
public void EveryUserImplicitlyReceivesPlayerIncludingUnknownOfflineUsers()
{
var policy = new PolicySnapshot( 0, new[] { new GroupPolicy( "player", permissions: new[] { Entry( PermissionEffect.Allow ) } ) } );
var result = new PermissionResolver( policy, Registered ).Explain( User, Key );
Assert.True( result.IsAllowed );
Assert.Equal( "player", Assert.Single( result.Groups ).GroupId );
}
[Fact]
public void EveryPolicyRevisionInvalidatesDirectMembershipAndInheritedDecisions()
{
var resolver = new PermissionResolver( Snapshot( 1, PermissionEffect.Allow, false, true ), Registered );
var cached = resolver.Resolver.Explain( User, Key );
Assert.Same( cached, resolver.Resolver.Explain( User, Key ) );
Assert.True( cached.IsAllowed );
resolver.ReplacePolicy( Snapshot( 2, PermissionEffect.Unset, false, true ) );
Assert.False( resolver.Explain( User, Key ).IsAllowed );
Assert.Equal( 2, resolver.Explain( User, Key ).PolicyRevision );
Assert.NotSame( cached, resolver.Resolver.Explain( User, Key ) );
resolver.ReplacePolicy( Snapshot( 3, PermissionEffect.Unset, true, false ) );
Assert.True( resolver.Explain( User, Key ).IsAllowed );
resolver.ReplacePolicy( new PolicySnapshot( 4, new[] { new GroupPolicy( "player" ) } ) );
Assert.False( resolver.Explain( User, Key ).IsAllowed );
Assert.Throws<ArgumentException>( () => resolver.ReplacePolicy( Snapshot( 4, PermissionEffect.Allow, true, false ) ) );
Assert.Throws<ArgumentException>( () => resolver.ReplacePolicy( Snapshot( 1, PermissionEffect.Allow, true, false ) ) );
Assert.False( resolver.Explain( User, Key ).IsAllowed );
}
[Fact]
public void RegistryReplacementInvalidatesBothUnknownAndPreviouslyKnownDecisionsAtomically()
{
var resolver = new PermissionResolver( Snapshot( 1, PermissionEffect.Allow, false, false ), Array.Empty<PermissionDescriptor>() );
Assert.False( resolver.Explain( User, Key ).IsAllowed );
var oldRegistry = resolver.RegistryRevision;
resolver.ReplaceRegisteredPermissions( Registered );
Assert.True( resolver.Explain( User, Key ).IsAllowed );
Assert.True( resolver.RegistryRevision > oldRegistry );
Assert.Throws<ArgumentException>( () => resolver.ReplaceRegisteredPermissions( Registered.Concat( Registered ) ) );
Assert.True( resolver.Explain( User, Key ).IsAllowed );
resolver.ReplaceRegisteredPermissions( Array.Empty<PermissionDescriptor>() );
Assert.False( resolver.Explain( User, Key ).IsAllowed );
}
[Theory]
[InlineData( 1 )]
[InlineData( 2 )]
[InlineData( 3 )]
public void InheritanceCyclesAreRejectedBeforePolicyActivation( int count )
{
var groups = new List<GroupPolicy> { new( "player" ) };
for ( var i = 0; i < count; i++ )
{
groups.Add( new GroupPolicy( "group" + i, new[] { "group" + ((i + 1) % count) } ) );
}
var error = Assert.Throws<ArgumentException>( () => new PolicySnapshot( 1, groups ) );
Assert.Contains( "cycle", error.Message );
}
[Fact]
public void MissingGroupsAndDuplicateUsersOrGroupsAreRejected()
{
Assert.Throws<ArgumentException>( () => new PolicySnapshot( 0, new[] { new GroupPolicy( "staff" ) } ) );
Assert.Throws<ArgumentException>( () => new PolicySnapshot( 0, new[] { new GroupPolicy( "player", new[] { "missing" } ) } ) );
Assert.Throws<ArgumentException>( () => new PolicySnapshot( 0, new[] { new GroupPolicy( "player" ) }, new[] { new UserPolicy( User, new[] { "missing" } ) } ) );
Assert.Throws<ArgumentException>( () => new PolicySnapshot( 0, new[] { new GroupPolicy( "player" ), new GroupPolicy( "player" ) } ) );
Assert.Throws<ArgumentException>( () => new PolicySnapshot( 0, new[] { new GroupPolicy( "player" ) }, new[] { new UserPolicy( User ), new UserPolicy( User ) } ) );
}
[Fact]
public void DiamondInheritanceContributesEachGroupOnlyOnce()
{
var policy = new PolicySnapshot( 1, new[]
{
new GroupPolicy( "player", permissions: new[] { Entry( PermissionEffect.Deny ) } ),
new GroupPolicy( "left", new[] { "player" } ), new GroupPolicy( "right", new[] { "player" } ),
new GroupPolicy( "staff", new[] { "left", "right" } )
}, new[] { new UserPolicy( User, new[] { "staff", "left" } ) } );
var result = new PermissionResolver( policy, Registered ).Explain( User, Key );
Assert.False( result.IsAllowed );
Assert.Single( result.Groups );
}
[Fact]
public void CallerMutationsCannotChangePolicyAndUnsetRemovesTheDirectEntry()
{
var parents = new[] { "player" };
var entries = new[] { Entry( PermissionEffect.Allow ) };
var assignments = new[] { "staff" };
var group = new GroupPolicy( "staff", parents, entries );
var user = new UserPolicy( User, assignments, new[] { Entry( PermissionEffect.Unset ) } );
parents[ 0 ] = "missing";
entries[ 0 ] = Entry( PermissionEffect.Deny );
assignments[ 0 ] = "missing";
var policy = new PolicySnapshot( 1, new[] { new GroupPolicy( "player" ), group }, new[] { user } );
Assert.True( new PermissionResolver( policy, Registered ).Explain( User, Key ).IsAllowed );
Assert.Empty( user.Permissions );
Assert.Throws<NotSupportedException>( () => ((IList<string>)group.Parents)[ 0 ] = "other" );
}
[Fact]
public void ReservedOwnerCannotBeCreatedInheritedOrAssignedAsAnOrdinaryGroup()
{
Assert.Throws<ArgumentException>( () => new GroupPolicy( "owner" ) );
Assert.Throws<ArgumentException>( () => new GroupPolicy( "staff", new[] { "owner" } ) );
Assert.Throws<ArgumentException>( () => new UserPolicy( User, new[] { "owner" } ) );
Assert.Throws<ArgumentException>( () => new GroupPolicy( "Owner" ) );
}
[Fact]
public void DefaultPolicyGrantsPublicHelpAndKeepsStaffAuthorityExplicit()
{
var policy = PolicySnapshot.CreateDefault();
Assert.Empty( policy.Users );
Assert.Equal( 0, policy.Groups.Single( group => group.Id == "player" ).ProtectionLevel );
Assert.Equal( 10, policy.Groups.Single( group => group.Id == "moderator" ).ProtectionLevel );
Assert.Equal( 50, policy.Groups.Single( group => group.Id == "admin" ).ProtectionLevel );
var resolver = new PermissionResolver( policy, Registered.Concat( new[] { new PermissionDescriptor( "admin.help", "Help" ) } ) );
Assert.True( resolver.Explain( User, "admin.help" ).IsAllowed );
Assert.False( resolver.Explain( User, Key ).IsAllowed );
Assert.DoesNotContain( policy.Groups.SelectMany( group => group.Permissions ), entry => entry.Key.StartsWith( "admin.security" ) );
}
}