using System.Collections.Concurrent; using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; namespace Campaign_Tracker.Server.Tests; /// /// Custom factory that replaces the file-backed IAuditService with an in-memory /// passthrough so integration tests have no file-system dependency. /// Keycloak configuration is also applied here so all tests inherit it without /// repeating SetEnvironmentVariable calls in every test constructor. /// public sealed class AuthIntegrationTestFactory : WebApplicationFactory { 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"; protected override void ConfigureWebHost(IWebHostBuilder builder) { // Apply Keycloak test configuration before the host builds. builder.UseSetting("Keycloak:Authority", Issuer); builder.UseSetting("Keycloak:ValidIssuer", Issuer); builder.UseSetting("Keycloak:PublicAuthority", Issuer); builder.UseSetting("Keycloak:ClientId", ClientId); builder.UseSetting("Keycloak:DisableHttpsMetadata", "true"); builder.UseSetting("Keycloak:TestSigningKey", SigningKey); builder.ConfigureServices(services => { // Replace the file-backed IAuditService with an in-memory passthrough. // File persistence is validated in AuditServiceTests; integration tests // should not depend on file-system state. var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService)); if (descriptor is not null) { services.Remove(descriptor); } services.AddSingleton(); }); } public static string CreateToken(string subject, string role, string audience = ClientId) { var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: Issuer, audience: audience, 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 sealed class InMemoryPassthroughAuditService : IAuditService { private readonly ConcurrentQueue _events = new(); public void Record(AuditEvent auditEvent) => _events.Enqueue(auditEvent); public IReadOnlyCollection GetRecent(int maxCount = 200) { if (maxCount < 0) { throw new ArgumentOutOfRangeException(nameof(maxCount)); } if (maxCount == 0) { return []; } var events = _events.ToArray(); return events.Length > maxCount ? events[^maxCount..] : events; } } } public class AuthEndpointTests : IClassFixture { private readonly AuthIntegrationTestFactory _factory; public AuthEndpointTests(AuthIntegrationTestFactory factory) { _factory = factory; } [Fact] public async Task SessionEndpoint_WithoutToken_ReturnsUnauthorized() { using var client = _factory.CreateClient(); var response = await client.GetAsync("/api/auth/session"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task SessionEndpoint_WithValidToken_ReturnsRoleWorkspaceAndAuditsSuccess() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("daniel@example.test", "client-services")); var httpResponse = await client.GetAsync("/api/auth/session"); var auditStore = _factory.Services.GetRequiredService(); Assert.True(httpResponse.IsSuccessStatusCode, string.Join("; ", auditStore.Events.Select(audit => audit.Reason))); var response = await httpResponse.Content.ReadFromJsonAsync(); Assert.NotNull(response); Assert.Equal("daniel@example.test", response.UserName); Assert.Contains("ClientServices", response.Roles); Assert.Equal("/workspace/client-services", response.WorkspacePath); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.Success && audit.Subject == "daniel@example.test"); } [Fact] public async Task SessionEndpoint_WithKeycloakDefaultAudience_IsAccepted() { // Keycloak's default access tokens carry aud="account" (the realm's // built-in account client) rather than the resource-server's ClientId. // The bearer middleware must accept "account" in addition to "canopy-web", // otherwise every real-world login fails with SecurityTokenInvalidAudienceException. using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken( "daniel@example.test", "client-services", audience: "account")); var response = await client.GetAsync("/api/auth/session"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task SessionEndpoint_WithInvalidToken_ReturnsUnauthorizedAndAuditsFailure() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid.jwt.value"); var response = await client.GetAsync("/api/auth/session"); var auditStore = _factory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.Failure && audit.Reason.Contains("invalid", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task TokenExchange_WhenSuccessful_RecordsSharedAuditEvent() { var stubFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(IKeycloakTokenClient)); if (descriptor is not null) { services.Remove(descriptor); } services.AddSingleton(); }); }); using var client = stubFactory.CreateClient(); var response = await client.PostAsJsonAsync("/api/auth/token/exchange", new { code = "code", redirectUri = "https://app.example.test/auth/callback", }); var auditService = stubFactory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains(auditService.GetRecent(), audit => audit.EventType == AuditEventType.SessionLogin && audit.ActorIdentity == "alice@example.test" && audit.Resource == "authentication/token/exchange"); } [Fact] public async Task TokenRefresh_WhenKeycloakRejects_RecordsSharedAuditFailure() { var rejectingFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(IKeycloakTokenClient)); if (descriptor is not null) { services.Remove(descriptor); } services.AddSingleton(); }); }); using var client = rejectingFactory.CreateClient(); var response = await client.PostAsJsonAsync("/api/auth/token/refresh", new { refreshToken = "expired-refresh-token", }); var auditService = rejectingFactory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains(auditService.GetRecent(), audit => audit.EventType == AuditEventType.SessionRefreshFailure && audit.Resource == "authentication/token/refresh"); } [Fact] public async Task LogoutEndpoint_WithValidIdTokenHint_Returns200AndAuditsSuccessfulLogout() { var stubFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(IKeycloakTokenClient)); if (descriptor is not null) { services.Remove(descriptor); } services.AddSingleton(); }); }); using var client = stubFactory.CreateClient(); var idToken = AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", idToken); var response = await client.PostAsJsonAsync("/api/auth/logout", new { idTokenHint = idToken }); var auditStore = stubFactory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.Logout && audit.Subject == "alice@example.test" && audit.Resource == "authentication/logout"); } [Fact] public async Task LogoutEndpoint_WhenKeycloakUnreachable_StillReturns200AndAuditsFailure() { var throwingFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(IKeycloakTokenClient)); if (descriptor is not null) { services.Remove(descriptor); } services.AddSingleton(); }); }); using var client = throwingFactory.CreateClient(); var idToken = AuthIntegrationTestFactory.CreateToken("bob@example.test", "production"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", idToken); var response = await client.PostAsJsonAsync("/api/auth/logout", new { idTokenHint = idToken }); var auditStore = throwingFactory.Services.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains(auditStore.Events, audit => audit.EventType == AuthenticationAuditEventType.Logout && audit.Resource == "authentication/logout"); } [Fact] public async Task LogoutEndpoint_WithEmptyIdTokenHint_ReturnsBadRequest() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services")); var response = await client.PostAsJsonAsync("/api/auth/logout", new { idTokenHint = "" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task LogoutEndpoint_WithMissingBody_ReturnsBadRequest() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services")); var response = await client.PostAsync("/api/auth/logout", null); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task SessionEndpoint_WhenAuditServiceUnavailable_BlocksAction_AC5() { var failingAuditFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService)); if (descriptor is not null) { services.Remove(descriptor); } services.AddSingleton(); }); }); using var client = failingAuditFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services")); var response = await client.GetAsync("/api/auth/session"); // Action must be blocked — not silently succeed (AC #5). Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); } private sealed class AuthSessionResponse { public string UserName { get; init; } = string.Empty; public string[] Roles { get; init; } = []; public string WorkspacePath { get; init; } = string.Empty; } private sealed class StubKeycloakTokenClient : IKeycloakTokenClient { public Task ExchangeAuthorizationCodeAsync( string code, string redirectUri, CancellationToken cancellationToken) => Task.FromResult(new AuthTokenSetResponse( AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"), "refresh", 9999)); public Task RefreshAccessTokenAsync( string refreshToken, CancellationToken cancellationToken) => Task.FromResult(new AuthTokenSetResponse( AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"), "refresh", 9999)); public Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class ThrowingKeycloakTokenClient : IKeycloakTokenClient { public Task ExchangeAuthorizationCodeAsync( string code, string redirectUri, CancellationToken cancellationToken) => Task.FromResult(new AuthTokenSetResponse("access", "refresh", 9999)); public Task RefreshAccessTokenAsync( string refreshToken, CancellationToken cancellationToken) => Task.FromResult(new AuthTokenSetResponse("access", "refresh", 9999)); public Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) => throw new HttpRequestException("Simulated Keycloak outage."); } private sealed class RejectingKeycloakTokenClient : IKeycloakTokenClient { public Task ExchangeAuthorizationCodeAsync( string code, string redirectUri, CancellationToken cancellationToken) => throw new KeycloakTokenRequestException(HttpStatusCode.BadRequest, "invalid_grant"); public Task RefreshAccessTokenAsync( string refreshToken, CancellationToken cancellationToken) => throw new KeycloakTokenRequestException(HttpStatusCode.BadRequest, "invalid_grant"); public Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class AlwaysFailingAuditService : IAuditService { public void Record(AuditEvent auditEvent) => throw new AuditServiceUnavailableException("Audit service unavailable (test stub).", new IOException("Simulated disk failure")); public IReadOnlyCollection GetRecent(int maxCount = 200) => []; } }