Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/AuditFailureAfterCommitTests.cs
T
Joseph Doherty e1487fbf10 fix(secrets): UI reports a committed change as saved when its audit throws (scadaproj#34)
SecretEditor and ConfirmDeleteModal wrote the success audit inside the same
try as the store write, so a throwing IAuditWriter after a committed
add/rotate/delete showed an error, wrote a Failure audit and skipped the
resolver-cache eviction - the old plaintext kept resolving for the TTL.

The mutation try now covers only the cipher + store call; the success audit
is best-effort afterwards and logs a warning (no value) on failure, so the
change is reported saved, the callback fires and the cache is evicted.

- tests: AuditFailureAfterCommitTests (add, rotate, delete) with a throwing
  success audit; all fail without the fix
- code-reviews: Secrets-013 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00

94 lines
4.1 KiB
C#

using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Ui.Components;
using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Ui.Tests;
/// <summary>
/// scadaproj#34 (Secrets-013): when the success audit throws AFTER the store committed, the UI must
/// still report the change as saved, raise its saved/deleted callback, evict the resolver cache, and
/// never audit the committed change as a Failure. Before the fix the store write and the success
/// audit shared one try, so a throwing audit sink turned a real rotation into "Unable to rotate the
/// secret.", a <c>Failure</c> audit row, and a resolver still serving the old plaintext.
/// </summary>
public sealed class AuditFailureAfterCommitTests : TestContext
{
private (RecordingSecretStore Store, CapturingAuditWriter Audit, RecordingCacheInvalidator Cache) Register()
{
var store = new RecordingSecretStore();
var audit = new CapturingAuditWriter { SuccessFault = new InvalidOperationException("audit sink down") };
var cache = new RecordingCacheInvalidator();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
Services.AddSingleton<ISecretCipher>(new FakeCipher());
Services.AddSingleton<ISecretCacheInvalidator>(cache);
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
return (store, audit, cache);
}
[Fact]
public void Rotate_is_reported_saved_and_evicted_when_the_success_audit_throws()
{
var (store, audit, cache) = Register();
var name = new SecretName("db/central");
bool saved = false;
var cut = RenderComponent<SecretEditor>(p => p
.Add(c => c.ExistingName, name)
.Add(c => c.OnSaved, () => saved = true));
cut.Find("[data-testid=secret-value]").Change("new-value");
cut.Find("[data-testid=editor-submit]").Click();
Assert.Single(store.Upserts); // committed
Assert.True(saved, "OnSaved was not raised for a committed rotation");
Assert.Equal("db/central", Assert.Single(cache.Invalidated).Value);
Assert.Empty(cut.FindAll("[data-testid=editor-error]"));
Assert.DoesNotContain(audit.Events, e => e.Outcome == AuditOutcome.Failure);
}
[Fact]
public void Add_is_reported_saved_and_evicted_when_the_success_audit_throws()
{
var (store, audit, cache) = Register();
bool saved = false;
var cut = RenderComponent<SecretEditor>(p => p.Add(c => c.OnSaved, () => saved = true));
cut.Find("[data-testid=secret-name]").Change("app/thing");
cut.Find("[data-testid=secret-value]").Change("v");
cut.Find("[data-testid=editor-submit]").Click();
Assert.Single(store.Upserts);
Assert.True(saved);
Assert.Equal("app/thing", Assert.Single(cache.Invalidated).Value);
Assert.Empty(cut.FindAll("[data-testid=editor-error]"));
Assert.DoesNotContain(audit.Events, e => e.Outcome == AuditOutcome.Failure);
}
[Fact]
public void Delete_is_reported_deleted_and_evicted_when_the_success_audit_throws()
{
var (store, audit, cache) = Register();
var name = new SecretName("db/central");
bool deleted = false;
var cut = RenderComponent<ConfirmDeleteModal>(p => p
.Add(c => c.Visible, true)
.Add(c => c.Name, name)
.Add(c => c.OnDeleted, () => deleted = true));
cut.Find("[data-testid=confirm-delete]").Click();
Assert.Single(store.Deletes); // tombstoned
Assert.True(deleted, "OnDeleted was not raised for a committed delete");
Assert.Equal("db/central", Assert.Single(cache.Invalidated).Value);
Assert.DoesNotContain("Unable to delete", cut.Markup, StringComparison.Ordinal);
Assert.DoesNotContain(audit.Events, e => e.Outcome == AuditOutcome.Failure);
}
}