using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System.Threading.RateLimiting; using Xunit; namespace JobTrackerApi.Tests; public sealed class AdminProcessingControllerTests { [Theory] [InlineData("jobId=9999")] [InlineData("createdFrom=2099-01-01T00:00:00Z")] [InlineData("createdBefore=2000-01-01T00:00:00Z")] [InlineData("worker=untracked")] [InlineData("q=SECRET_CV_CONTENT")] public async Task Diagnostic_filters_are_applied_before_pagination(string query) { await using var host = await Host.Start(); using var client = host.Client("admin"); var result = await client.GetFromJsonAsync("/api/admin/processing?" + query); Assert.Equal(0, result.GetProperty("total").GetInt32()); Assert.Empty(result.GetProperty("items").EnumerateArray()); } [Theory] [InlineData("jobId=-1")] [InlineData("worker=SECRET")] [InlineData("createdFrom=0001-01-01T00:00:00Z")] [InlineData("failureCode=SECRET")] [InlineData("createdFrom=2026-11-01T00:00:00Z&createdBefore=2026-10-01T00:00:00Z")] public async Task Invalid_diagnostic_filters_fail_closed(string query) { await using var host = await Host.Start(); using var client = host.Client("admin"); Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/admin/processing?" + query)).StatusCode); } [Theory] [InlineData("jobId=2", 1)] [InlineData("q=Owned%20job%20title", 1)] [InlineData("q=owner%40example.test", 2)] [InlineData("worker=document", 1)] [InlineData("worker=ai", 2)] [InlineData("failureCode=timeout", 1)] [InlineData("failureCode=processing_failed", 1)] [InlineData("createdBefore=2026-10-16T00:00:00Z", 1)] public async Task Diagnostic_filters_preserve_actual_owner_relationships(string query, int expected) { await using var host = await Host.Start(); using var client = host.Client("admin"); var result = await client.GetFromJsonAsync("/api/admin/processing?" + query + "&pageSize=1"); Assert.Equal(expected, result.GetProperty("total").GetInt32()); Assert.Single(result.GetProperty("items").EnumerateArray()); Assert.DoesNotContain("SECRET", result.GetRawText()); } [Fact] public async Task Detail_exposes_import_identity_and_links_only_the_administrators_own_application() { await using var host = await Host.Start(); using var admin = host.Client("admin"); var document = await admin.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}"); Assert.Equal(1, document.GetProperty("document").GetProperty("importId").GetInt32()); var job = await admin.GetFromJsonAsync($"/api/admin/processing/{host.JobOperationId}"); Assert.False(job.GetProperty("job").GetProperty("canOpen").GetBoolean()); using var ownerAdmin = host.Client("owner-admin"); job = await ownerAdmin.GetFromJsonAsync($"/api/admin/processing/{host.JobOperationId}"); Assert.True(job.GetProperty("job").GetProperty("canOpen").GetBoolean()); var search = await admin.GetFromJsonAsync($"/api/admin/processing?q={host.OperationId}"); Assert.Equal(1, search.GetProperty("total").GetInt32()); } [Fact] public async Task Processing_reads_share_a_bounded_account_rate_limit() { await using var host = await Host.Start(2); using var admin = host.Client("admin"); Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/api/admin/processing")).StatusCode); Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync($"/api/admin/processing/{host.OperationId}")).StatusCode); Assert.Equal(HttpStatusCode.TooManyRequests, (await admin.GetAsync($"/api/admin/processing/{host.OperationId}/events")).StatusCode); using var other = host.Client("owner-admin"); Assert.Equal(HttpStatusCode.OK, (await other.GetAsync("/api/admin/processing")).StatusCode); } [Theory] [InlineData("succeeded")] [InlineData("waiting_for_retry")] public async Task Failure_metadata_uses_the_recorded_failure_instead_of_later_success(string status) { await using var host = await Host.Start(); var failedAt = DateTime.UtcNow.AddMinutes(-2); await using (var scope = host.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == host.OperationId); operation.Status = status; operation.FailureCategory = "timeout"; operation.CompletedAtUtc = status == "succeeded" ? failedAt.AddMinutes(1) : null; operation.ProgressStage = "cv_review_ready"; db.UserOperationEvents.Add(new() { Id = 9007199254740995, OperationId = operation.Id, OwnerUserId = "owner", Kind = "RetryScheduled", Status = "waiting_for_retry", AtUtc = failedAt, Stage = "cv_extracting", FailureCode = "timeout" }); await db.SaveChangesAsync(); } using var client = host.Client("admin"); var result = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}"); Assert.Equal(status, result.GetProperty("status").GetString()); Assert.Equal(failedAt, result.GetProperty("lastFailureAtUtc").GetDateTime()); Assert.Equal("cv_extracting", result.GetProperty("lastFailureStage").GetString()); } [Fact] public async Task Missing_failure_history_does_not_turn_success_time_into_failure_evidence() { await using var host = await Host.Start(); await using (var scope = host.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == host.OperationId); operation.Status = "succeeded"; operation.FailureCategory = "timeout"; operation.CompletedAtUtc = DateTime.UtcNow; await db.SaveChangesAsync(); } using var client = host.Client("admin"); var result = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}"); Assert.Equal(JsonValueKind.Null, result.GetProperty("lastFailureAtUtc").ValueKind); Assert.Equal(JsonValueKind.Null, result.GetProperty("lastFailureStage").ValueKind); } [Fact] public async Task User_authored_identity_labels_are_bounded_before_admin_projection() { await using var host = await Host.Start(); using var client = host.Client("admin"); var detail = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}"); var name = detail.GetProperty("owner").GetProperty("name").GetString()!; Assert.True(name.Length <= 161); Assert.EndsWith("…", name); } [Theory] [InlineData(null, HttpStatusCode.Unauthorized)] [InlineData("ordinary", HttpStatusCode.Forbidden)] [InlineData("admin", HttpStatusCode.OK)] public async Task Processing_routes_require_the_server_administrator_role(string? identity, HttpStatusCode expected) { await using var host = await Host.Start(); using var client = host.Client(identity); foreach (var route in new[] { "/api/admin/processing", $"/api/admin/processing/{host.OperationId}", $"/api/admin/processing/{host.OperationId}/events" }) Assert.Equal(expected, (await client.GetAsync(route)).StatusCode); } [Fact] public async Task Admin_projection_filters_owners_and_never_returns_raw_processing_contents_or_lease_data() { await using var host = await Host.Start(); using var client = host.Client("admin"); var listing = await client.GetFromJsonAsync("/api/admin/processing?owner=owner&status=running&pageSize=1"); Assert.Equal(1, listing.GetProperty("total").GetInt32()); Assert.Single(listing.GetProperty("items").EnumerateArray()); var detail = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}"); Assert.Contains("owner@example.test", detail.GetRawText()); Assert.Contains("CV.pdf", detail.GetRawText()); Assert.Equal(2, detail.GetProperty("document").GetProperty("pages").GetInt32()); Assert.DoesNotContain("folder", detail.GetProperty("document").GetProperty("name").GetString()); Assert.DoesNotContain("SECRET", detail.GetRawText()); Assert.DoesNotContain("SECRET", listing.GetRawText()); var foreign = await client.GetFromJsonAsync($"/api/admin/processing/{host.ForeignSubjectOperationId}"); Assert.Equal(JsonValueKind.Null, foreign.GetProperty("job").ValueKind); Assert.DoesNotContain("Owned job title", foreign.GetRawText()); } [Fact] public async Task Event_cursor_is_exact_ordered_bounded_retained_and_redacted() { await using var host = await Host.Start(); using var client = host.Client("admin"); var first = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}/events?take=1"); Assert.True(first.GetProperty("hasMore").GetBoolean()); var item = Assert.Single(first.GetProperty("items").EnumerateArray()); Assert.Equal("9007199254740994", item.GetProperty("id").GetString()); Assert.DoesNotContain("SECRET", first.GetRawText()); var after = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}/events?after=9007199254740993"); Assert.Single(after.GetProperty("items").EnumerateArray()); var before = await client.GetFromJsonAsync($"/api/admin/processing/{host.OperationId}/events?before=9007199254740994"); Assert.Equal("9007199254740993", Assert.Single(before.GetProperty("items").EnumerateArray()).GetProperty("id").GetString()); foreach (var query in new[] { "take=101", "after=-1", "after=1&before=2" }) Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/admin/processing/{host.OperationId}/events?{query}")).StatusCode); } private sealed class Host(WebApplication app, string directory, Guid operationId, Guid foreignSubjectId) : IAsyncDisposable { public IServiceProvider Services => app.Services; public Guid OperationId => operationId; public Guid ForeignSubjectOperationId => foreignSubjectId; public Guid JobOperationId => Guid.Parse("22222222-2222-4222-8222-222222222222"); public HttpClient Client(string? identity) { var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService().Features.Get()!.Addresses.Single()) }; if (identity is not null) client.DefaultRequestHeaders.Add("X-Test-Identity", identity); return client; } public static async Task Start(int permits = 120) { var directory = Path.Combine(Path.GetTempPath(), "admin-processing-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly); builder.Services.AddAuthentication("test").AddScheme("test", _ => { }); builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy(AdminProcessingController.RateLimitPolicy, context => RateLimitPartition.GetFixedWindowLimiter( RateLimitPartitionKeys.Account(context, AdminProcessingController.RateLimitPolicy), _ => new FixedWindowRateLimiterOptions { PermitLimit = permits, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); }); builder.Services.AddScoped(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddDbContext(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False")); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.MapControllers(); var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid(); await using (var scope = app.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); db.Users.AddRange(new ApplicationUser { Id = "owner", Email = "owner@example.test", DisplayName = "Synthetic owner " + new string('n', 2000) }, new ApplicationUser { Id = "other", Email = "other@example.test" }); var company = new Company { Id = 1, OwnerUserId = "owner", Name = "Synthetic company" }; db.Companies.Add(company); db.JobApplications.Add(new() { Id = 2, OwnerUserId = "owner", CompanyId = 1, JobTitle = "Owned job title", Status = "Applied" }); db.CvUploadArtifacts.Add(new() { Id = 1, OwnerUserId = "owner", OriginalFileName = "../folder/CV.pdf", MimeType = "application/pdf", ByteSize = 500, StoragePath = "SECRET_PATH" }); db.CvExtractionRuns.Add(new() { Id = 1, ArtifactId = 1, OwnerUserId = "owner", Trigger = "document_import", ParserVersion = "docling-v1", DocumentJson = "{\"PageCount\":2,\"Text\":\"SECRET_CV_CONTENT\"}" }); db.UserOperations.AddRange(new UserOperation { Id = operationId, OwnerUserId = "owner", IdempotencyKey = "one", TaskType = "cv.process", SubjectType = "cv_extraction_run", SubjectId = "1", Status = "running", CreatedAtUtc = DateTime.UtcNow, LeaseToken = "SECRET_TOKEN", FailureMessage = "SECRET_EXCEPTION", FailureCategory = "SECRET_CATEGORY", ResultReference = "SECRET_RESULT", ProgressStage = "SECRET_STAGE" }, new UserOperation { Id = foreign, OwnerUserId = "other", IdempotencyKey = "two", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "queued", CreatedAtUtc = DateTime.UtcNow }, new UserOperation { Id = Guid.Parse("22222222-2222-4222-8222-222222222222"), OwnerUserId = "owner", IdempotencyKey = "three", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "failed", FailureCategory = "timeout", CreatedAtUtc = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc) }); db.UserOperationEvents.AddRange( new UserOperationEvent { Id = 100, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow.AddDays(-31), Kind = "Created", Status = "queued" }, new UserOperationEvent { Id = 9007199254740993, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "Created", Status = "running" }, new UserOperationEvent { Id = 9007199254740994, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "SECRET_EVENT", Status = "SECRET_STATUS", Stage = "SECRET_STAGE", FailureCode = "SECRET_FAILURE" }); await db.SaveChangesAsync(); } await app.StartAsync(); return new(app, directory, operationId, foreign); } public async ValueTask DisposeAsync() { await app.DisposeAsync(); Directory.Delete(directory, true); } } private sealed class TestAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)) return Task.FromResult(AuthenticateResult.NoResult()); var claims = new List { new(ClaimTypes.NameIdentifier, identity == "owner-admin" ? "owner" : identity.ToString()) }; if (identity == "admin" || identity == "owner-admin") claims.Add(new(ClaimTypes.Role, "Admin")); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), "test"))); } } }