Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

427 Zeilen
18KB

  1. using System.Collections.Concurrent;
  2. using System.IdentityModel.Tokens.Jwt;
  3. using System.Net;
  4. using System.Net.Http.Json;
  5. using System.Net.Http.Headers;
  6. using System.Security.Claims;
  7. using System.Text;
  8. using Campaign_Tracker.Server.Audit;
  9. using Campaign_Tracker.Server.Authentication;
  10. using Campaign_Tracker.Server.LegacyData;
  11. using Microsoft.AspNetCore.Hosting;
  12. using Microsoft.AspNetCore.Mvc.Testing;
  13. using Microsoft.Extensions.DependencyInjection;
  14. using Microsoft.IdentityModel.Tokens;
  15. namespace Campaign_Tracker.Server.Tests;
  16. /// <summary>
  17. /// Custom factory that replaces the file-backed IAuditService with an in-memory
  18. /// passthrough so integration tests have no file-system dependency.
  19. /// Keycloak configuration is also applied here so all tests inherit it without
  20. /// repeating SetEnvironmentVariable calls in every test constructor.
  21. /// </summary>
  22. public sealed class AuthIntegrationTestFactory : WebApplicationFactory<Program>
  23. {
  24. private const string Issuer = "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI";
  25. private const string ClientId = "canopy-web";
  26. private const string SigningKey = "test-signing-key-with-at-least-32-characters";
  27. protected override void ConfigureWebHost(IWebHostBuilder builder)
  28. {
  29. // Apply Keycloak test configuration before the host builds.
  30. builder.UseSetting("Keycloak:Authority", Issuer);
  31. builder.UseSetting("Keycloak:ValidIssuer", Issuer);
  32. builder.UseSetting("Keycloak:PublicAuthority", Issuer);
  33. builder.UseSetting("Keycloak:ClientId", ClientId);
  34. builder.UseSetting("Keycloak:DisableHttpsMetadata", "true");
  35. builder.UseSetting("Keycloak:TestSigningKey", SigningKey);
  36. builder.UseSetting("LegacySchema:HistoryFile",
  37. Path.Combine(Path.GetTempPath(), $"campaign-tracker-schema-history-{Guid.NewGuid():N}.jsonl"));
  38. builder.UseSetting("LegacyLinkIntegrity:Enabled", "false");
  39. builder.ConfigureServices(services =>
  40. {
  41. // Replace the file-backed IAuditService with an in-memory passthrough.
  42. // File persistence is validated in AuditServiceTests; integration tests
  43. // should not depend on file-system state.
  44. var auditDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService));
  45. if (auditDescriptor is not null)
  46. services.Remove(auditDescriptor);
  47. services.AddSingleton<IAuditService, InMemoryPassthroughAuditService>();
  48. // Replace the data-file-backed ILegacyDataAccess with hardcoded test defaults so
  49. // integration tests are not affected by the presence or contents of a seed file.
  50. var legacyDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(ILegacyDataAccess));
  51. if (legacyDescriptor is not null)
  52. services.Remove(legacyDescriptor);
  53. services.AddSingleton<ILegacyDataAccess>(new InMemoryLegacyDataAccess());
  54. });
  55. }
  56. public static string CreateToken(string subject, string role, string audience = ClientId)
  57. {
  58. var credentials = new SigningCredentials(
  59. new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)),
  60. SecurityAlgorithms.HmacSha256);
  61. var token = new JwtSecurityToken(
  62. issuer: Issuer,
  63. audience: audience,
  64. claims:
  65. [
  66. new Claim(JwtRegisteredClaimNames.Sub, subject),
  67. new Claim(ClaimTypes.Name, subject),
  68. new Claim(ClaimTypes.Role, role),
  69. ],
  70. expires: DateTime.UtcNow.AddMinutes(10),
  71. signingCredentials: credentials);
  72. return new JwtSecurityTokenHandler().WriteToken(token);
  73. }
  74. private sealed class InMemoryPassthroughAuditService : IAuditService
  75. {
  76. private readonly ConcurrentQueue<AuditEvent> _events = new();
  77. public void Record(AuditEvent auditEvent) => _events.Enqueue(auditEvent);
  78. public IReadOnlyCollection<AuditEvent> GetRecent(int maxCount = 200)
  79. {
  80. if (maxCount < 0)
  81. {
  82. throw new ArgumentOutOfRangeException(nameof(maxCount));
  83. }
  84. if (maxCount == 0)
  85. {
  86. return [];
  87. }
  88. var events = _events.ToArray();
  89. return events.Length > maxCount ? events[^maxCount..] : events;
  90. }
  91. }
  92. }
  93. public class AuthEndpointTests : IClassFixture<AuthIntegrationTestFactory>
  94. {
  95. private readonly AuthIntegrationTestFactory _factory;
  96. public AuthEndpointTests(AuthIntegrationTestFactory factory)
  97. {
  98. _factory = factory;
  99. }
  100. [Fact]
  101. public async Task SessionEndpoint_WithoutToken_ReturnsUnauthorized()
  102. {
  103. using var client = _factory.CreateClient();
  104. var response = await client.GetAsync("/api/auth/session");
  105. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  106. }
  107. [Fact]
  108. public async Task SessionEndpoint_WithValidToken_ReturnsRoleWorkspaceAndAuditsSuccess()
  109. {
  110. using var client = _factory.CreateClient();
  111. client.DefaultRequestHeaders.Authorization =
  112. new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("daniel@example.test", "client-services"));
  113. var httpResponse = await client.GetAsync("/api/auth/session");
  114. var auditStore = _factory.Services.GetRequiredService<IAuthenticationAuditStore>();
  115. Assert.True(httpResponse.IsSuccessStatusCode, string.Join("; ", auditStore.Events.Select(audit => audit.Reason)));
  116. var response = await httpResponse.Content.ReadFromJsonAsync<AuthSessionResponse>();
  117. Assert.NotNull(response);
  118. Assert.Equal("daniel@example.test", response.UserName);
  119. Assert.Contains("ClientServices", response.Roles);
  120. Assert.Equal("/workspace/client-services", response.WorkspacePath);
  121. Assert.Contains(auditStore.Events, audit =>
  122. audit.EventType == AuthenticationAuditEventType.Success &&
  123. audit.Subject == "daniel@example.test");
  124. }
  125. [Fact]
  126. public async Task SessionEndpoint_WithKeycloakDefaultAudience_IsAccepted()
  127. {
  128. // Keycloak's default access tokens carry aud="account" (the realm's
  129. // built-in account client) rather than the resource-server's ClientId.
  130. // The bearer middleware must accept "account" in addition to "canopy-web",
  131. // otherwise every real-world login fails with SecurityTokenInvalidAudienceException.
  132. using var client = _factory.CreateClient();
  133. client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
  134. "Bearer",
  135. AuthIntegrationTestFactory.CreateToken(
  136. "daniel@example.test",
  137. "client-services",
  138. audience: "account"));
  139. var response = await client.GetAsync("/api/auth/session");
  140. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  141. }
  142. [Fact]
  143. public async Task SessionEndpoint_WithInvalidToken_ReturnsUnauthorizedAndAuditsFailure()
  144. {
  145. using var client = _factory.CreateClient();
  146. client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid.jwt.value");
  147. var response = await client.GetAsync("/api/auth/session");
  148. var auditStore = _factory.Services.GetRequiredService<IAuthenticationAuditStore>();
  149. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  150. Assert.Contains(auditStore.Events, audit =>
  151. audit.EventType == AuthenticationAuditEventType.Failure &&
  152. audit.Reason.Contains("invalid", StringComparison.OrdinalIgnoreCase));
  153. }
  154. [Fact]
  155. public async Task TokenExchange_WhenSuccessful_RecordsSharedAuditEvent()
  156. {
  157. var stubFactory = _factory.WithWebHostBuilder(builder =>
  158. {
  159. builder.ConfigureServices(services =>
  160. {
  161. var descriptor = services.SingleOrDefault(
  162. d => d.ServiceType == typeof(IKeycloakTokenClient));
  163. if (descriptor is not null)
  164. {
  165. services.Remove(descriptor);
  166. }
  167. services.AddSingleton<IKeycloakTokenClient, StubKeycloakTokenClient>();
  168. });
  169. });
  170. using var client = stubFactory.CreateClient();
  171. var response = await client.PostAsJsonAsync("/api/auth/token/exchange", new
  172. {
  173. code = "code",
  174. redirectUri = "https://app.example.test/auth/callback",
  175. });
  176. var auditService = stubFactory.Services.GetRequiredService<IAuditService>();
  177. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  178. Assert.Contains(auditService.GetRecent(), audit =>
  179. audit.EventType == AuditEventType.SessionLogin &&
  180. audit.ActorIdentity == "alice@example.test" &&
  181. audit.Resource == "authentication/token/exchange");
  182. }
  183. [Fact]
  184. public async Task TokenRefresh_WhenKeycloakRejects_RecordsSharedAuditFailure()
  185. {
  186. var rejectingFactory = _factory.WithWebHostBuilder(builder =>
  187. {
  188. builder.ConfigureServices(services =>
  189. {
  190. var descriptor = services.SingleOrDefault(
  191. d => d.ServiceType == typeof(IKeycloakTokenClient));
  192. if (descriptor is not null)
  193. {
  194. services.Remove(descriptor);
  195. }
  196. services.AddSingleton<IKeycloakTokenClient, RejectingKeycloakTokenClient>();
  197. });
  198. });
  199. using var client = rejectingFactory.CreateClient();
  200. var response = await client.PostAsJsonAsync("/api/auth/token/refresh", new
  201. {
  202. refreshToken = "expired-refresh-token",
  203. });
  204. var auditService = rejectingFactory.Services.GetRequiredService<IAuditService>();
  205. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  206. Assert.Contains(auditService.GetRecent(), audit =>
  207. audit.EventType == AuditEventType.SessionRefreshFailure &&
  208. audit.Resource == "authentication/token/refresh");
  209. }
  210. [Fact]
  211. public async Task LogoutEndpoint_WithValidIdTokenHint_Returns200AndAuditsSuccessfulLogout()
  212. {
  213. var stubFactory = _factory.WithWebHostBuilder(builder =>
  214. {
  215. builder.ConfigureServices(services =>
  216. {
  217. var descriptor = services.SingleOrDefault(
  218. d => d.ServiceType == typeof(IKeycloakTokenClient));
  219. if (descriptor is not null)
  220. {
  221. services.Remove(descriptor);
  222. }
  223. services.AddSingleton<IKeycloakTokenClient, StubKeycloakTokenClient>();
  224. });
  225. });
  226. using var client = stubFactory.CreateClient();
  227. var idToken = AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services");
  228. client.DefaultRequestHeaders.Authorization =
  229. new AuthenticationHeaderValue("Bearer", idToken);
  230. var response = await client.PostAsJsonAsync("/api/auth/logout", new { idTokenHint = idToken });
  231. var auditStore = stubFactory.Services.GetRequiredService<IAuthenticationAuditStore>();
  232. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  233. Assert.Contains(auditStore.Events, audit =>
  234. audit.EventType == AuthenticationAuditEventType.Logout &&
  235. audit.Subject == "alice@example.test" &&
  236. audit.Resource == "authentication/logout");
  237. }
  238. [Fact]
  239. public async Task LogoutEndpoint_WhenKeycloakUnreachable_StillReturns200AndAuditsFailure()
  240. {
  241. var throwingFactory = _factory.WithWebHostBuilder(builder =>
  242. {
  243. builder.ConfigureServices(services =>
  244. {
  245. var descriptor = services.SingleOrDefault(
  246. d => d.ServiceType == typeof(IKeycloakTokenClient));
  247. if (descriptor is not null)
  248. {
  249. services.Remove(descriptor);
  250. }
  251. services.AddSingleton<IKeycloakTokenClient, ThrowingKeycloakTokenClient>();
  252. });
  253. });
  254. using var client = throwingFactory.CreateClient();
  255. var idToken = AuthIntegrationTestFactory.CreateToken("bob@example.test", "production");
  256. client.DefaultRequestHeaders.Authorization =
  257. new AuthenticationHeaderValue("Bearer", idToken);
  258. var response = await client.PostAsJsonAsync("/api/auth/logout", new { idTokenHint = idToken });
  259. var auditStore = throwingFactory.Services.GetRequiredService<IAuthenticationAuditStore>();
  260. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  261. Assert.Contains(auditStore.Events, audit =>
  262. audit.EventType == AuthenticationAuditEventType.Logout &&
  263. audit.Resource == "authentication/logout");
  264. }
  265. [Fact]
  266. public async Task LogoutEndpoint_WithEmptyIdTokenHint_ReturnsBadRequest()
  267. {
  268. using var client = _factory.CreateClient();
  269. client.DefaultRequestHeaders.Authorization =
  270. new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"));
  271. var response = await client.PostAsJsonAsync("/api/auth/logout", new { idTokenHint = "" });
  272. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  273. }
  274. [Fact]
  275. public async Task LogoutEndpoint_WithMissingBody_ReturnsBadRequest()
  276. {
  277. using var client = _factory.CreateClient();
  278. client.DefaultRequestHeaders.Authorization =
  279. new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"));
  280. var response = await client.PostAsync("/api/auth/logout", null);
  281. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  282. }
  283. [Fact]
  284. public async Task SessionEndpoint_WhenAuditServiceUnavailable_BlocksAction_AC5()
  285. {
  286. var failingAuditFactory = _factory.WithWebHostBuilder(builder =>
  287. {
  288. builder.ConfigureServices(services =>
  289. {
  290. var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService));
  291. if (descriptor is not null)
  292. {
  293. services.Remove(descriptor);
  294. }
  295. services.AddSingleton<IAuditService, AlwaysFailingAuditService>();
  296. });
  297. });
  298. using var client = failingAuditFactory.CreateClient();
  299. client.DefaultRequestHeaders.Authorization =
  300. new AuthenticationHeaderValue("Bearer", AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"));
  301. var response = await client.GetAsync("/api/auth/session");
  302. // Action must be blocked — not silently succeed (AC #5).
  303. Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
  304. }
  305. private sealed class AuthSessionResponse
  306. {
  307. public string UserName { get; init; } = string.Empty;
  308. public string[] Roles { get; init; } = [];
  309. public string WorkspacePath { get; init; } = string.Empty;
  310. }
  311. private sealed class StubKeycloakTokenClient : IKeycloakTokenClient
  312. {
  313. public Task<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync(
  314. string code, string redirectUri, CancellationToken cancellationToken) =>
  315. Task.FromResult(new AuthTokenSetResponse(
  316. AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"),
  317. "refresh",
  318. 9999));
  319. public Task<AuthTokenSetResponse> RefreshAccessTokenAsync(
  320. string refreshToken, CancellationToken cancellationToken) =>
  321. Task.FromResult(new AuthTokenSetResponse(
  322. AuthIntegrationTestFactory.CreateToken("alice@example.test", "client-services"),
  323. "refresh",
  324. 9999));
  325. public Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) =>
  326. Task.CompletedTask;
  327. }
  328. private sealed class ThrowingKeycloakTokenClient : IKeycloakTokenClient
  329. {
  330. public Task<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync(
  331. string code, string redirectUri, CancellationToken cancellationToken) =>
  332. Task.FromResult(new AuthTokenSetResponse("access", "refresh", 9999));
  333. public Task<AuthTokenSetResponse> RefreshAccessTokenAsync(
  334. string refreshToken, CancellationToken cancellationToken) =>
  335. Task.FromResult(new AuthTokenSetResponse("access", "refresh", 9999));
  336. public Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) =>
  337. throw new HttpRequestException("Simulated Keycloak outage.");
  338. }
  339. private sealed class RejectingKeycloakTokenClient : IKeycloakTokenClient
  340. {
  341. public Task<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync(
  342. string code, string redirectUri, CancellationToken cancellationToken) =>
  343. throw new KeycloakTokenRequestException(HttpStatusCode.BadRequest, "invalid_grant");
  344. public Task<AuthTokenSetResponse> RefreshAccessTokenAsync(
  345. string refreshToken, CancellationToken cancellationToken) =>
  346. throw new KeycloakTokenRequestException(HttpStatusCode.BadRequest, "invalid_grant");
  347. public Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) =>
  348. Task.CompletedTask;
  349. }
  350. private sealed class AlwaysFailingAuditService : IAuditService
  351. {
  352. public void Record(AuditEvent auditEvent) =>
  353. throw new AuditServiceUnavailableException("Audit service unavailable (test stub).",
  354. new IOException("Simulated disk failure"));
  355. public IReadOnlyCollection<AuditEvent> GetRecent(int maxCount = 200) => [];
  356. }
  357. }

Powered by TurnKey Linux.