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); } private sealed class CaptureHandler : HttpMessageHandler { public string? ExternalAllowed { get; private set; } public string? TaskType { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { 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(); } } }