fix(admin): complete processing diagnostics and failure evidence

This commit is contained in:
cesnimda
2026-10-29 07:24:49 +01:00
parent 0dd788bdfc
commit 3255da9fc6
15 changed files with 450 additions and 36 deletions
@@ -10,14 +10,132 @@ 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<JsonElement>("/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<JsonElement>("/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<JsonElement>($"/api/admin/processing/{host.OperationId}");
Assert.Equal(1, document.GetProperty("document").GetProperty("importId").GetInt32());
var job = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.JobOperationId}");
Assert.False(job.GetProperty("job").GetProperty("canOpen").GetBoolean());
using var ownerAdmin = host.Client("owner-admin");
job = await ownerAdmin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.JobOperationId}");
Assert.True(job.GetProperty("job").GetProperty("canOpen").GetBoolean());
var search = await admin.GetFromJsonAsync<JsonElement>($"/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<JobTrackerContext>();
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<JsonElement>($"/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<JobTrackerContext>();
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<JsonElement>($"/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()
{
@@ -81,15 +199,17 @@ public sealed class AdminProcessingControllerTests
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<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
if (identity is not null) client.DefaultRequestHeaders.Add("X-Test-Identity", identity);
return client;
}
public static async Task<Host> Start()
public static async Task<Host> Start(int permits = 120)
{
var directory = Path.Combine(Path.GetTempPath(), "admin-processing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
@@ -98,11 +218,18 @@ public sealed class AdminProcessingControllerTests
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("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<ICurrentUserService, CurrentUserService>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False"));
var app = builder.Build();
app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.MapControllers();
var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid();
await using (var scope = app.Services.CreateAsyncScope())
{
@@ -115,7 +242,8 @@ public sealed class AdminProcessingControllerTests
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 = 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" },
@@ -131,8 +259,8 @@ public sealed class AdminProcessingControllerTests
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)) return Task.FromResult(AuthenticateResult.NoResult());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, identity.ToString()) };
if (identity == "admin") claims.Add(new(ClaimTypes.Role, "Admin"));
var claims = new List<Claim> { 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")));
}
}