using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; namespace SeaHavenIndustries.Tests; /// /// Public-interface authorization tests for . These /// prove the controller-facing admin surface enforces an authenticated Admin principal at service /// entry (defense in depth beyond the HTTP [Authorize] filter) and rejects non-Admin or /// unauthenticated principals BEFORE any data-service call. The trusted runner surface is also /// covered to confirm it is the path that performs the actual data work. /// public sealed class WorkOrderReconciliationAuthTests { private static ClaimsPrincipal Unauthenticated() => new(new ClaimsIdentity()); private static ClaimsPrincipal AuthenticatedNonAdmin() => new(new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "vendor") }, "Test")); private static ClaimsPrincipal AuthenticatedAdmin() => new(new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "admin@seahavenind.com"), new Claim(ClaimTypes.Role, "Admin") }, "Test")); private static WorkOrderReconciliationService CreateService( SpyJobs jobs, bool enabled) => new( new NoopClient(), new NoopWorkOrders(), jobs, new TestOptions(new WorkOrderReconciliationOptions { Enabled = enabled, LeaseSeconds = 120 }), TimeProvider.System, NullLogger.Instance); [Fact] public async Task Unauthenticated_trigger_is_rejected_before_data_access() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: true); await Assert.ThrowsAsync(() => service.TriggerAsync(Unauthenticated(), "admin", CancellationToken.None)); AssertDataAccessBlocked(jobs); } [Fact] public async Task Non_admin_trigger_is_rejected_before_data_access() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: true); await Assert.ThrowsAsync(() => service.TriggerAsync(AuthenticatedNonAdmin(), "admin", CancellationToken.None)); AssertDataAccessBlocked(jobs); } [Fact] public async Task Unauthenticated_status_is_rejected_before_data_access() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: true); await Assert.ThrowsAsync(() => service.GetStatusAsync(Unauthenticated(), CancellationToken.None)); AssertDataAccessBlocked(jobs); } [Fact] public async Task Non_admin_status_is_rejected_before_data_access() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: true); await Assert.ThrowsAsync(() => service.GetStatusAsync(AuthenticatedNonAdmin(), CancellationToken.None)); AssertDataAccessBlocked(jobs); } [Fact] public async Task Admin_trigger_delegates_to_runner_with_admin_reason() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: true); var result = await service.TriggerAsync(AuthenticatedAdmin(), "admin", CancellationToken.None); Assert.True(result.Queued); Assert.NotEqual(Guid.Empty, result.RunId); Assert.True(jobs.GetStatusCalls >= 1); Assert.Equal(1, jobs.EnqueueCalls); Assert.Equal("admin", jobs.LastEnqueuedReason); } [Fact] public async Task Admin_status_returns_reconciliation_state_after_data_access() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: true); var status = await service.GetStatusAsync(AuthenticatedAdmin(), CancellationToken.None); Assert.Equal(1, jobs.GetStatusCalls); Assert.NotNull(status); Assert.Equal("Idle", status.State); } [Fact] public async Task Admin_trigger_when_disabled_returns_not_queued_without_enqueue() { var jobs = new SpyJobs(); IWorkOrderReconciliationService service = CreateService(jobs, enabled: false); var result = await service.TriggerAsync(AuthenticatedAdmin(), "admin", CancellationToken.None); Assert.False(result.Queued); Assert.Equal(Guid.Empty, result.RunId); Assert.Equal(0, jobs.EnqueueCalls); } private static void AssertDataAccessBlocked(SpyJobs jobs) { Assert.Equal(0, jobs.GetStatusCalls); Assert.Equal(0, jobs.EnqueueCalls); } private sealed class SpyJobs : IWorkOrderReconciliationDataService { public int GetStatusCalls { get; private set; } public int EnqueueCalls { get; private set; } public string? LastEnqueuedReason { get; private set; } public Task EnqueueAsync( string reason, DateTimeOffset now, CancellationToken cancellationToken) { EnqueueCalls++; LastEnqueuedReason = reason; return Task.FromResult(new ReconciliationJobSnapshot( Guid.NewGuid(), "Pending", reason, now, null, null, 0, 0, null)); } public Task TryAcquirePendingAsync( DateTimeOffset now, TimeSpan leaseDuration, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task GetStatusAsync(CancellationToken cancellationToken) { GetStatusCalls++; return Task.FromResult(new ReconciliationJobSnapshot( Guid.NewGuid(), "Idle", null, null, null, null, 0, 0, null)); } public Task RenewLeaseAsync( Guid runId, Guid fenceToken, DateTimeOffset now, TimeSpan leaseDuration, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task CompleteAsync( Guid runId, Guid fenceToken, DateTimeOffset completedAt, int workOrders, int comments, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task FailAsync( Guid runId, Guid fenceToken, DateTimeOffset completedAt, string errorCode, CancellationToken cancellationToken) => throw new NotImplementedException(); } private sealed class NoopClient : IProcurementWorkOrderClient { public Task> GetWorkOrdersAsync( string? cursor, int limit, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task> GetCommentsAsync( string workOrderId, string? cursor, int limit, CancellationToken cancellationToken) => throw new NotImplementedException(); } private sealed class NoopWorkOrders : IWorkOrderWebhookDataService { public Task ApplyAsync( WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) => throw new NotImplementedException(); } private sealed class TestOptions : IOptionsMonitor { public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value; public WorkOrderReconciliationOptions CurrentValue { get; } public WorkOrderReconciliationOptions Get(string? name) => CurrentValue; public IDisposable? OnChange( Action listener) => null; } }