Files
jobtrackingapp/JobTrackerApi.Tests/AiPrivacyPolicyTests.cs
T

211 lines
9.2 KiB
C#

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<HttpResponseMessage> 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<Fixture> CreateAsync(bool adminEnabled, string routingMode = "local_first")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Ai:ExternalProcessingEnabled"] = adminEnabled.ToString(),
["Ai:ExternalProvider"] = "gemini",
["Ai:RoutingMode"] = routingMode,
}).Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddHttpContextAccessor();
services.AddScoped<CurrentUserService>();
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<JobTrackerContext>().Database.EnsureCreatedAsync();
return new Fixture(connection, provider, new AiPrivacyPolicy(configuration, provider.GetRequiredService<IServiceScopeFactory>()));
}
public async Task CreateUserAsync(bool externalAllowed, bool pro, bool aiEnabled = true)
{
await using var scope = _services.CreateAsyncScope();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (pro) await roles.CreateAsync(new IdentityRole("Premium"));
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
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();
}
}
}