Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions test/Classes/DuoAuthenticationRequirementTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Web.Authorization;

namespace Viper.test.Classes
{
/// <summary>
/// Pins the deliberate Development bypass, since no Duo credential can be issued for a localhost
/// callback, and pins that the failure message is set only when the requirement fails.
/// </summary>
public class DuoAuthenticationRequirementTests
{
private const string ErrorKey = "ErrorMessage";

private static HttpContext ContextFor(string environmentName)
{
var env = Substitute.For<IWebHostEnvironment>();
env.EnvironmentName = environmentName;

var services = new ServiceCollection();
services.AddSingleton(env);

return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
}

private static ClaimsPrincipal UserWithDuo() =>
new(new ClaimsIdentity(new[] { new Claim("credentialType", "DuoCredential") }, "test"));

private static async Task<(bool Succeeded, object? Error)> EvaluateAsync(ClaimsPrincipal user, string environmentName)
{
var httpContext = ContextFor(environmentName);
var requirement = new DuoAuthenticationRequirement();
var context = new AuthorizationHandlerContext(new[] { requirement }, user, httpContext);

await requirement.HandleAsync(context);

httpContext.Items.TryGetValue(ErrorKey, out object? error);
return (context.HasSucceeded, error);
}

[Fact]
public async Task DuoCredential_Succeeds()
{
var (succeeded, error) = await EvaluateAsync(UserWithDuo(), "Production");

Assert.True(succeeded);
Assert.Null(error);
}

[Fact]
public async Task Development_SucceedsWithoutDuo()
{
var (succeeded, error) = await EvaluateAsync(new ClaimsPrincipal(new ClaimsIdentity()), "Development");

Assert.True(succeeded);
// The bypass is not a failure, so it must not leave a failure message behind.
Assert.Null(error);
}

[Theory]
[InlineData("Production")]
[InlineData("Test")]
public async Task OutsideDevelopment_FailsWithoutDuoAndExplainsWhy(string environmentName)
{
var (succeeded, error) = await EvaluateAsync(new ClaimsPrincipal(new ClaimsIdentity()), environmentName);

Assert.False(succeeded);
Assert.Equal("DUO two-factor authentication is required", error);
}
}
}
96 changes: 96 additions & 0 deletions test/Classes/UserHelperCacheTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Microsoft.Extensions.Caching.Memory;
using Viper.Models.AAUD;

namespace Viper.test.Classes
{
[Collection(HttpHelperCacheCollection.Name)]
public sealed class UserHelperCacheTests : IDisposable
{
private MemoryCache? _installed;

// Empty, not dispose: HttpHelper.Cache is a process-wide static, so a disposed instance would
// make every later test throw.
public void Dispose()
{
_installed?.Clear();
}

private const string MothraId = "00012345";

// Spelled out rather than shared with UserHelper so a rename there has to be deliberate.
private static readonly string[] CacheKeys =
{
"Roles-" + MothraId,
"PermissionsAssigned-" + MothraId + "-True",
"PermissionsAssigned-" + MothraId + "-False",
"PermissionsInherited-" + MothraId + "-True",
"PermissionsInherited-" + MothraId + "-False",
};

private IMemoryCache ConfigureCache()
{
var memoryCache = new MemoryCache(new MemoryCacheOptions());
HttpHelper.Configure(memoryCache, null!, null!, null!, null!, null!);
_installed = memoryCache;
return memoryCache;
}

[Fact]
public void ClearCachedRolesAndPermissions_RemovesEveryRoleAndPermissionKey()
{
var memoryCache = ConfigureCache();
foreach (string key in CacheKeys)
{
memoryCache.Set(key, "cached");
}

new UserHelper().ClearCachedRolesAndPermissions(new AaudUser { MothraId = MothraId });

foreach (string key in CacheKeys)
{
Assert.False(memoryCache.TryGetValue(key, out _), $"{key} was left in the cache");
}
}

[Fact]
public void ClearCachedRolesAndPermissions_LeavesOtherUsersEntriesAlone()
{
var memoryCache = ConfigureCache();
memoryCache.Set("Roles-99999999", "cached");

new UserHelper().ClearCachedRolesAndPermissions(new AaudUser { MothraId = MothraId });

Assert.True(memoryCache.TryGetValue("Roles-99999999", out _));
}

[Fact]
public void ClearCachedRolesAndPermissions_ByMothraId_NeedsNoAaudUser()
{
var memoryCache = ConfigureCache();
foreach (string key in CacheKeys)
{
memoryCache.Set(key, "cached");
}

// The interceptor only has the RAPS-side id, so this overload is the one it calls.
UserHelper.ClearCachedRolesAndPermissions(MothraId);

foreach (string key in CacheKeys)
{
Assert.False(memoryCache.TryGetValue(key, out _), $"{key} was left in the cache");
}
}

[Fact]
public void ClearCachedRolesAndPermissions_IgnoresAnEmptyMothraId()
{
var memoryCache = ConfigureCache();
memoryCache.Set("Roles-", "cached");

UserHelper.ClearCachedRolesAndPermissions(string.Empty);

// Guards the old LoginId behaviour, where users without one shared the "Roles-" key.
Assert.True(memoryCache.TryGetValue("Roles-", out _));
}
}
}
1 change: 1 addition & 0 deletions test/ClinicalScheduler/CliniciansControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace Viper.test.ClinicalScheduler
{
[Collection(HttpHelperCacheCollection.Name)]
public class CliniciansControllerTest : ClinicalSchedulerTestBase
{
private readonly AAUDContext _aaudContext;
Expand Down
1 change: 1 addition & 0 deletions test/ClinicalScheduler/EmailNotificationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace Viper.test.ClinicalScheduler
/// Tests for email notification functionality when primary evaluators are changed.
/// Covers email sending, content validation, and error handling scenarios.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class EmailNotificationTest : IDisposable
{
private readonly IScheduleAuditService _mockAuditService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ namespace Viper.test.ClinicalScheduler.Integration
/// because the PermissionAttribute creates its own UserHelper instance, preventing proper mocking.
/// These tests are kept for documentation purposes and can be converted to unit tests.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class ControllerServiceIntegrationTest : IntegrationTestBase
{
private readonly IPersonService _personService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Viper.test.ClinicalScheduler.Integration
/// Tests the complete flow of permission checks after consolidating
/// ClinicalScheduleSecurityService into SchedulePermissionService.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class PermissionServiceIntegrationTest : IntegrationTestBase
{
private readonly ILogger<SchedulePermissionService> _mockLogger;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace Viper.test.ClinicalScheduler.Integration
/// Tests the new service architecture with StudentScheduleService, InstructorScheduleService,
/// and how ClinicalScheduleService delegates to them.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class ServiceLayerIntegrationTest : IntegrationTestBase
{
private static readonly DateTime ScheduleStart = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Local);
Expand Down
1 change: 1 addition & 0 deletions test/ClinicalScheduler/PermissionsControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace Viper.test.ClinicalScheduler
{
[Collection(HttpHelperCacheCollection.Name)]
public class PermissionsControllerTest : ClinicalSchedulerTestBase
{
private readonly ISchedulePermissionService _mockPermissionService;
Expand Down
1 change: 1 addition & 0 deletions test/ClinicalScheduler/RotationsControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Viper.test.ClinicalScheduler
{
[Collection(HttpHelperCacheCollection.Name)]
public class RotationsControllerTest : ClinicalSchedulerTestBase
{
private readonly ILogger<RotationsController> _mockLogger;
Expand Down
1 change: 1 addition & 0 deletions test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace Viper.test.ClinicalScheduler
/// prove a failed audit write rolls the schedule change back, not just that the
/// exception surfaces.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class ScheduleEditServiceRollbackTest : IDisposable
{
private readonly SqliteConnection _connection;
Expand Down
1 change: 1 addition & 0 deletions test/ClinicalScheduler/ScheduleEditServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Viper.test.ClinicalScheduler
{
[Collection(HttpHelperCacheCollection.Name)]
public class ScheduleEditServiceTest : IDisposable
{
private readonly ISchedulePermissionService _mockPermissionService;
Expand Down
1 change: 1 addition & 0 deletions test/ClinicalScheduler/SchedulePermissionServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Viper.test.ClinicalScheduler
{
[Collection(HttpHelperCacheCollection.Name)]
public class SchedulePermissionServiceTest : ClinicalSchedulerTestBase
{
private readonly ILogger<SchedulePermissionService> _mockLogger;
Expand Down
1 change: 1 addition & 0 deletions test/Effort/EffortTypesControllerIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ namespace Viper.test.Effort;
/// Integration tests for EffortTypesController.
/// Tests the full stack: Controller -> Service -> DbContext.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class EffortTypesControllerIntegrationTests : EffortIntegrationTestBase
{
private readonly EffortTypesController _controller;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Viper.test.Effort.Integration;
/// Tests the complete flow of permission checks for department-level,
/// full access, and self-service permission models.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class EffortPermissionIntegrationTests : EffortIntegrationTestBase
{
private readonly EffortPermissionService _permissionService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Viper.test.Effort;
/// Integration tests for PercentAssignTypesController.
/// Tests the full stack: Controller -> Service -> DbContext.
/// </summary>
[Collection(HttpHelperCacheCollection.Name)]
public class PercentAssignTypesControllerIntegrationTests : EffortIntegrationTestBase
{
private readonly PercentAssignTypesController _controller;
Expand Down
11 changes: 11 additions & 0 deletions test/HttpHelperCacheCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Viper.test;

/// <summary>
/// HttpHelper.Configure swaps a process-wide static cache, and xUnit runs test classes in parallel by
/// default. Every class that configures the cache must join this collection.
/// </summary>
[CollectionDefinition(HttpHelperCacheCollection.Name)]
public static class HttpHelperCacheCollection
{
public const string Name = "HttpHelperCache";
}
Loading
Loading