mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
215 lines
8.1 KiB
C#
215 lines
8.1 KiB
C#
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;
|
|
}
|
|
}
|