|
- 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<WebApplicationFactory<Program>>
- {
- 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<Program> _factory;
-
- public ApplicationAuthorizationTests(WebApplicationFactory<Program> 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<IAuthenticationAuditStore>();
-
- 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<IAuthenticationAuditStore>();
-
- 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<IAuthenticationAuditStore>();
-
- 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<IAuthenticationAuditStore>();
-
- 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);
- }
- }
|