feat(management): server-side secured-write TTL — stale pending writes expire, never execute (arch-review S2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -53,6 +53,12 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => _auditRepo);
|
||||
_services.AddSingleton<CommunicationService>(_comms);
|
||||
|
||||
// Server-side secured-write TTL (arch-review S2): the expiry helper reads
|
||||
// SecuredWritePendingTtl off IOptions<ManagementServiceOptions>. Register the
|
||||
// defaults (24 h TTL) so the TTL tests exercise the real threshold.
|
||||
_services.AddSingleton<IOptions<ManagementServiceOptions>>(
|
||||
Options.Create(new ManagementServiceOptions()));
|
||||
|
||||
// #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same
|
||||
// central-direct-write path Notification Outbox dispatch + Inbound API use) so
|
||||
// these tests exercise SourceNode stamping end-to-end — actor → writer → repo.
|
||||
@@ -607,6 +613,71 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("not found", updated.ExecutionError);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Server-side TTL expiry — Task 15 (arch-review S2)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
|
||||
{
|
||||
var row = SeedPendingWrite(1, operatorUser: "alice");
|
||||
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25); // TTL default 24h
|
||||
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "bob", "Verifier"));
|
||||
|
||||
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
|
||||
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
// The stale setpoint is NEVER relayed to the site.
|
||||
Assert.Equal(0, _comms.CallCount);
|
||||
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_PendingRowOlderThanTtl_ExpiresInsteadOfRejecting()
|
||||
{
|
||||
var row = SeedPendingWrite(1, operatorUser: "alice");
|
||||
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25);
|
||||
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new RejectSecuredWriteCommand(1, null), "bob", "Verifier"));
|
||||
|
||||
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
|
||||
// The reject path never overwrites the row (the CAS expiry owns the transition).
|
||||
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
|
||||
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_FreshPendingRow_StillExecutes()
|
||||
{
|
||||
// Submitted 1h ago → within the 24h TTL, so the existing approve path is unchanged.
|
||||
var row = SeedPendingWrite(7, operatorUser: "alice");
|
||||
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-1);
|
||||
ArmCasSuccess(7);
|
||||
|
||||
PendingSecuredWrite? updated = null;
|
||||
_securedWriteRepo
|
||||
.When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => updated = ci.Arg<PendingSecuredWrite>());
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier"));
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkExpiredAsync(default, default, default);
|
||||
Assert.Equal(1, _comms.CallCount);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Executed", updated!.Status);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Audit emission (T14b — SecuredWrite AuditLog rows)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user