Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

417 строки
17KB

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

Powered by TurnKey Linux.