您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

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

Powered by TurnKey Linux.