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:
Joseph Doherty
2026-07-10 05:38:19 -04:00
parent b3e2294e1d
commit 064a7d9415
7 changed files with 129 additions and 2 deletions
@@ -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)
// ------------------------------------------------------------------------