using System.Security.Claims; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Xunit; namespace JobTrackerApi.Tests; public sealed class AiPrivacyPolicyTests { [Theory] [InlineData(false, true, true, "local")] [InlineData(true, false, true, "local")] [InlineData(true, true, false, "local")] [InlineData(true, true, true, "gemini")] public async Task External_processing_requires_admin_gate_user_consent_and_pro( bool adminEnabled, bool userAllowed, bool pro, string expectedProvider) { await using var fixture = await Fixture.CreateAsync(adminEnabled); await fixture.CreateUserAsync(userAllowed, pro); var decision = await fixture.Policy.EvaluateAsync("user-1"); Assert.Equal(expectedProvider != "local", decision.ExternalProcessingAllowed); Assert.Equal(expectedProvider, decision.Provider); } [Fact] public async Task Disabled_ai_always_forces_local_processing() { await using var fixture = await Fixture.CreateAsync(adminEnabled: true); await fixture.CreateUserAsync(externalAllowed: true, pro: true, aiEnabled: false); var decision = await fixture.Policy.EvaluateAsync("user-1"); Assert.False(decision.ExternalProcessingAllowed); Assert.Equal("local", decision.Provider); } [Theory] [InlineData("local_only")] [InlineData("unexpected")] public async Task Local_only_or_invalid_admin_mode_disables_external_processing(string routingMode) { await using var fixture = await Fixture.CreateAsync(adminEnabled: true, routingMode: routingMode); await fixture.CreateUserAsync(externalAllowed: true, pro: true); var decision = await fixture.Policy.EvaluateAsync("user-1"); Assert.False(decision.ExternalProcessingAllowed); Assert.Equal("local", decision.Provider); } [Fact] public async Task Cv_request_carries_external_permission_only_after_the_policy_allows_it() { await using var fixture = await Fixture.CreateAsync(adminEnabled: true); await fixture.CreateUserAsync(externalAllowed: true, pro: true); var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")), }; var capture = new CaptureHandler(); var handler = new AiPrivacyHeaderHandler( new HttpContextAccessor { HttpContext = context }, fixture.Policy, new AiOperationExecutionScope()) { InnerHandler = capture, }; using var client = new HttpClient(handler); await client.PostAsync("http://ai-service/cv/rewrite", new StringContent("{}")); Assert.Equal("true", capture.ExternalAllowed); } [Fact] public async Task Background_operation_carries_its_rechecked_policy_and_task_without_http_user_context() { await using var fixture = await Fixture.CreateAsync(adminEnabled: true); var executionScope = new AiOperationExecutionScope(); var capture = new CaptureHandler(); var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, executionScope) { InnerHandler = capture, }; var lease = new UserOperationLease(Guid.NewGuid(), "user-1", "lease", "strategy.snapshot", "external_allowed", "job", "42", 1, DateTime.UtcNow.AddMinutes(5)); using var routing = executionScope.Use(new AiOperationExecutionContext(lease, "external_allowed")); using var client = new HttpClient(handler); await client.PostAsync("http://ai-service/cv/rewrite", new StringContent("{}")); Assert.Equal("true", capture.ExternalAllowed); Assert.Equal("strategy.snapshot", capture.TaskType); } [Fact] public async Task Correlation_comes_only_from_the_actual_operation_scope_for_all_sidecar_paths() { await using var fixture = await Fixture.CreateAsync(adminEnabled: false); var execution = new AiOperationExecutionScope(); var capture = new CaptureHandler(); using var client = new HttpClient(new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, execution) { InnerHandler = capture }); var id = Guid.NewGuid(); var lease = new UserOperationLease(id, "user-1", "lease", "cv.process", "local_only", "cv_extraction_run", "1", 1, null); using (execution.Use(new(lease, "local_only"))) { using var request = new HttpRequestMessage(HttpMethod.Post, "http://ai-service/cv-import/extract"); request.Headers.Add("X-Operation-Id", "SECRET_untrusted_header"); await client.SendAsync(request); Assert.Equal(id.ToString("D"), capture.Correlation); } using var ordinary = new HttpRequestMessage(HttpMethod.Get, "http://ai-service/health"); ordinary.Headers.Add("X-Operation-Id", "SECRET_untrusted_header"); await client.SendAsync(ordinary); Assert.Null(capture.Correlation); } private sealed class CaptureHandler : HttpMessageHandler { public string? ExternalAllowed { get; private set; } public string? TaskType { get; private set; } public string? Correlation { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Correlation = request.Headers.TryGetValues("X-Operation-Id", out var correlation) ? correlation.Single() : null; ExternalAllowed = request.Headers.TryGetValues(AiPrivacyPolicy.ExternalAllowedHeader, out var values) ? values.Single() : null; TaskType = request.Headers.TryGetValues(AiPrivacyPolicy.TaskTypeHeader, out var taskValues) ? taskValues.Single() : null; return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); } } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly ServiceProvider _services; public AiPrivacyPolicy Policy { get; } private Fixture(SqliteConnection connection, ServiceProvider services, AiPrivacyPolicy policy) { _connection = connection; _services = services; Policy = policy; } public static async Task CreateAsync(bool adminEnabled, string routingMode = "local_first") { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Ai:ExternalProcessingEnabled"] = adminEnabled.ToString(), ["Ai:ExternalProvider"] = "gemini", ["Ai:RoutingMode"] = routingMode, }).Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); services.AddDbContext((_, options) => options.UseSqlite(connection)); services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); var provider = services.BuildServiceProvider(); await using var scope = provider.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); return new Fixture(connection, provider, new AiPrivacyPolicy(configuration, provider.GetRequiredService())); } public async Task CreateUserAsync(bool externalAllowed, bool pro, bool aiEnabled = true) { await using var scope = _services.CreateAsyncScope(); var roles = scope.ServiceProvider.GetRequiredService>(); if (pro) await roles.CreateAsync(new IdentityRole("Premium")); var users = scope.ServiceProvider.GetRequiredService>(); var user = new ApplicationUser { Id = "user-1", UserName = "synthetic@example.test", Email = "synthetic@example.test", AiEnabled = aiEnabled, ExternalAiProcessingAllowed = externalAllowed, }; Assert.True((await users.CreateAsync(user)).Succeeded); if (pro) Assert.True((await users.AddToRoleAsync(user, "Premium")).Succeeded); } public async ValueTask DisposeAsync() { await _services.DisposeAsync(); await _connection.DisposeAsync(); } } }