e1487fbf10
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
94 lines
4.1 KiB
C#
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);
|
|
}
|
|
}
|