shoc-backend/SeaHavenIndustries.Tests/WorkOrderReconciliationAuthTests.cs

216 lines
8.1 KiB
C#
Raw Normal View History

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;
/// <summary>
/// Public-interface authorization tests for <see cref="WorkOrderReconciliationService"/>. 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.
/// </summary>
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<WorkOrderReconciliationService>.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<UnauthorizedAccessException>(() =>
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<UnauthorizedAccessException>(() =>
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<UnauthorizedAccessException>(() =>
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<UnauthorizedAccessException>(() =>
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<ReconciliationJobSnapshot> 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<ReconciliationLease?> TryAcquirePendingAsync(
DateTimeOffset now, TimeSpan leaseDuration, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken)
{
GetStatusCalls++;
return Task.FromResult(new ReconciliationJobSnapshot(
Guid.NewGuid(), "Idle", null, null, null, null, 0, 0, null));
}
public Task<bool> 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<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
string? cursor, int limit, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
string workOrderId, string? cursor, int limit,
CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class NoopWorkOrders : IWorkOrderWebhookDataService
{
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
{
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
public WorkOrderReconciliationOptions CurrentValue { get; }
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderReconciliationOptions, string?> listener) => null;
}
}