using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using Campaign_Tracker.Server.Authentication; using Campaign_Tracker.Server.Authorization; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; namespace Campaign_Tracker.Server.Tests; public sealed class ApplicationAuthorizationTests : IClassFixture> { private const string Issuer = "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI"; private const string ClientId = "canopy-web"; private const string SigningKey = "test-signing-key-with-at-least-32-characters"; private readonly WebApplicationFactory _factory; public ApplicationAuthorizationTests(WebApplicationFactory factory) { Environment.SetEnvironmentVariable("Keycloak__Authority", Issuer); Environment.SetEnvironmentVariable("Keycloak__ValidIssuer", Issuer); Environment.SetEnvironmentVariable("Keycloak__PublicAuthority", Issuer); Environment.SetEnvironmentVariable("Keycloak__ClientId", ClientId); Environment.SetEnvironmentVariable("Keycloak__DisableHttpsMetadata", "true"); Environment.SetEnvironmentVariable("Keycloak__TestSigningKey", SigningKey); _factory = factory; } [Fact] public async Task ClientServices_CanAccessMunicipalityAndCycleRoutes_ButNotAdminOrProduction() { using var client = CreateClientWithRole("ClientServices"); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/municipalities/profile")).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/api/election-cycles", null)).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/settings")).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/production/work-queue")).StatusCode); } [Fact] public async Task Admin_CanAccessAllApplicationRoutes() { using var client = CreateClientWithRole("Admin"); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/municipalities/profile")).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/api/election-cycles", null)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/admin/settings")).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/production/work-queue")).StatusCode); } [Fact] public async Task KeycloakRealmAccessRole_IsMappedToApplicationPolicy() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateTokenWithRealmAccessRole("daniel@example.test", "ClientServices")); var response = await client.GetAsync("/api/municipalities/profile"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task UnrecognizedRole_ReceivesForbidden_AndAuditCapturesActor() { using var client = CreateClientWithRole("SeasonalViewer", "unknown@example.test"); var response = await client.GetAsync("/api/municipalities/profile"); var auditStore = _factory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.AuthorizationDenied && audit.Subject == "unknown@example.test" && audit.Resource == "/api/municipalities/profile"); } [Fact] public async Task PrivilegedOperation_AuditsAllowedAuthorizationResultActorAndResource() { using var client = CreateClientWithRole("Admin", "admin@example.test"); var response = await client.PostAsync("/api/admin/privileged-operation", null); var auditStore = _factory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.AuthorizationAllowed && audit.Subject == "admin@example.test" && audit.Resource == "/api/admin/privileged-operation"); } [Fact] public async Task AllowedPermissionCheck_AuditsAuthorizationAllowedCentrally() { using var client = CreateClientWithRole("ClientServices", "client@example.test"); var response = await client.GetAsync("/api/municipalities/profile"); var auditStore = _factory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.AuthorizationAllowed && audit.Subject == "client@example.test" && audit.Resource == "/api/municipalities/profile"); } [Fact] public async Task AnonymousPermissionCheck_AuditsAuthorizationDenied() { using var client = _factory.CreateClient(); var response = await client.GetAsync("/api/municipalities/profile"); var auditStore = _factory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.AuthorizationDenied && audit.Subject == "anonymous" && audit.Resource == "/api/municipalities/profile"); } [Fact] public void RoleExtraction_WhenKeycloakJsonClaimIsMalformed_IgnoresClaim() { var roles = ApplicationRole.ExtractKeycloakRoles( [new Claim("realm_access", "{not-json")], ClientId); Assert.Empty(roles); } [Fact] public void WorkspaceResolver_WhenUserHasMultipleRoles_UsesStablePriority() { var path = RoleWorkspaceResolver.ResolveWorkspacePath( [ApplicationRole.Production, ApplicationRole.Admin]); Assert.Equal("/workspace/admin", path); } private HttpClient CreateClientWithRole(string role, string subject = "daniel@example.test") { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateToken(subject, role)); return client; } private static string CreateToken(string subject, string role) { var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: Issuer, audience: ClientId, claims: [ new Claim(JwtRegisteredClaimNames.Sub, subject), new Claim(ClaimTypes.Name, subject), new Claim(ClaimTypes.Role, role), ], expires: DateTime.UtcNow.AddMinutes(10), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } private static string CreateTokenWithRealmAccessRole(string subject, string role) { var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: Issuer, audience: ClientId, claims: [ new Claim(JwtRegisteredClaimNames.Sub, subject), new Claim(ClaimTypes.Name, subject), new Claim("realm_access", $$"""{"roles":["{{role}}"]}""", JsonClaimValueTypes.Json), ], expires: DateTime.UtcNow.AddMinutes(10), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } }