|
- 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 Campaign_Tracker.Server.LegacyData;
- using Microsoft.AspNetCore.Hosting;
- using Microsoft.AspNetCore.Mvc.Testing;
- using Microsoft.Extensions.DependencyInjection;
- using Microsoft.IdentityModel.Tokens;
-
- namespace Campaign_Tracker.Server.Tests;
-
- /// <summary>
- /// 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.
- /// </summary>
- public sealed class AuthIntegrationTestFactory : 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";
-
- 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.UseSetting("LegacySchema:HistoryFile",
- Path.Combine(Path.GetTempPath(), $"campaign-tracker-schema-history-{Guid.NewGuid():N}.jsonl"));
- builder.UseSetting("LegacyLinkIntegrity:Enabled", "false");
-
- 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 auditDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService));
- if (auditDescriptor is not null)
- services.Remove(auditDescriptor);
-
- services.AddSingleton<IAuditService, InMemoryPassthroughAuditService>();
-
- // Replace the data-file-backed ILegacyDataAccess with hardcoded test defaults so
- // integration tests are not affected by the presence or contents of a seed file.
- var legacyDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(ILegacyDataAccess));
- if (legacyDescriptor is not null)
- services.Remove(legacyDescriptor);
-
- services.AddSingleton<ILegacyDataAccess>(new InMemoryLegacyDataAccess());
- });
- }
-
- 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<AuditEvent> _events = new();
-
- public void Record(AuditEvent auditEvent) => _events.Enqueue(auditEvent);
-
- public IReadOnlyCollection<AuditEvent> 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<AuthIntegrationTestFactory>
- {
- 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<IAuthenticationAuditStore>();
- Assert.True(httpResponse.IsSuccessStatusCode, string.Join("; ", auditStore.Events.Select(audit => audit.Reason)));
-
- var response = await httpResponse.Content.ReadFromJsonAsync<AuthSessionResponse>();
-
- 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<IAuthenticationAuditStore>();
-
- 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<IKeycloakTokenClient, StubKeycloakTokenClient>();
- });
- });
-
- 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<IAuditService>();
-
- 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<IKeycloakTokenClient, RejectingKeycloakTokenClient>();
- });
- });
-
- using var client = rejectingFactory.CreateClient();
- var response = await client.PostAsJsonAsync("/api/auth/token/refresh", new
- {
- refreshToken = "expired-refresh-token",
- });
- var auditService = rejectingFactory.Services.GetRequiredService<IAuditService>();
-
- 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<IKeycloakTokenClient, StubKeycloakTokenClient>();
- });
- });
-
- 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<IAuthenticationAuditStore>();
-
- 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<IKeycloakTokenClient, ThrowingKeycloakTokenClient>();
- });
- });
-
- 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<IAuthenticationAuditStore>();
-
- 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<IAuditService, AlwaysFailingAuditService>();
- });
- });
-
- 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<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync(
- string code, string redirectUri, CancellationToken cancellationToken) =>
- Task.FromResult(new AuthTokenSetResponse(
- AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"),
- "refresh",
- 9999));
-
- public Task<AuthTokenSetResponse> 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<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync(
- string code, string redirectUri, CancellationToken cancellationToken) =>
- Task.FromResult(new AuthTokenSetResponse("access", "refresh", 9999));
-
- public Task<AuthTokenSetResponse> 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<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync(
- string code, string redirectUri, CancellationToken cancellationToken) =>
- throw new KeycloakTokenRequestException(HttpStatusCode.BadRequest, "invalid_grant");
-
- public Task<AuthTokenSetResponse> 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<AuditEvent> GetRecent(int maxCount = 200) => [];
- }
- }
|