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) => [];
}
}