140 lines
9.5 KiB
C#
140 lines
9.5 KiB
C#
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.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class AdminProcessingControllerTests
|
|
{
|
|
[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<JsonElement>($"/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<JsonElement>("/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<JsonElement>($"/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<JsonElement>($"/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<JsonElement>($"/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<JsonElement>($"/api/admin/processing/{host.OperationId}/events?after=9007199254740993");
|
|
Assert.Single(after.GetProperty("items").EnumerateArray());
|
|
var before = await client.GetFromJsonAsync<JsonElement>($"/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 Guid OperationId => operationId;
|
|
public Guid ForeignSubjectOperationId => foreignSubjectId;
|
|
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()
|
|
{
|
|
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<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
|
|
builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor();
|
|
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();
|
|
var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid();
|
|
await using (var scope = app.Services.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
|
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 });
|
|
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<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
{
|
|
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"));
|
|
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), "test")));
|
|
}
|
|
}
|
|
}
|