diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index d7945472..ec7b7ab7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -836,13 +836,20 @@ akka {{ // it after an artifact deploy applies external-system changes. var externalSystemCache = _serviceProvider .GetService(); + // Task 10 (site-secrets artifact deploy): the same per-node singleton AddSiteRuntime + // registers (and AddHostedService runs periodically) is reconciled SYNCHRONOUSLY by + // HandleDeployArtifacts right after it writes a shipped secret set, so the deploy + // reply's SecretsApplied reflects this node's own apply/verify outcome immediately + // rather than waiting for the next periodic pass. + var secretsMirror = _serviceProvider + .GetService(); var dm = SingletonRegistrar.Start( _actorSystem!, "deployment-manager", Props.Create(() => new DeploymentManagerActor( storage, compilationService, sharedScriptLibrary, streamManager, siteRuntimeOptionsValue, dmLogger, dclManager, siteHealthCollector, _serviceProvider, null, deploymentConfigFetcher, - null, null, externalSystemCache)), + null, null, externalSystemCache, null, null, null, secretsMirror)), _logger, role: siteRole); var dmProxy = dm.Proxy; diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 87f6d379..d174c266 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -18,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; @@ -49,6 +50,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers /// external-system caching — the invalidation call is then simply skipped. /// private readonly ExternalSystemDefinitionCache? _externalSystemCache; + /// + /// Task 10 (site-secrets artifact deploy): per-node mirror reconciled synchronously by + /// right after the shipped secrets are written to + /// deployed_secrets, so the reply's SecretsApplied reflects this node's own + /// apply/verify outcome rather than the next periodic pass. Optional/null on nodes/tests + /// that never register secret deployment — a shipped command.Secrets with no mirror + /// registered fails the deploy (see ). + /// + private readonly DeployedSecretsMirror? _secretsMirror; private readonly SiteStreamManager? _streamManager; private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; @@ -306,6 +316,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers /// ensure-started heal uses; defaults to reading from . Primarily /// for tests that need to hold the read open mid-flight to drive the row-101 race. /// + /// + /// Task 10 (site-secrets artifact deploy): this node's , + /// reconciled synchronously by right after a shipped + /// command.Secrets set is written to deployed_secrets. Optional/null on + /// nodes/tests that never register secret deployment. + /// public DeploymentManagerActor( SiteStorageService storage, ScriptCompilationService compilationService, @@ -323,9 +339,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers ExternalSystemDefinitionCache? externalSystemCache = null, ScriptExecutionScheduler? scriptScheduler = null, Func>>? connectionDefinitionLoader = null, - Func>? deployedConfigLoader = null) + Func>? deployedConfigLoader = null, + DeployedSecretsMirror? secretsMirror = null) { _scriptScheduler = scriptScheduler; + _secretsMirror = secretsMirror; _storage = storage; _compilationService = compilationService; _deployCompileValidator = new DeployCompileValidator(compilationService); @@ -3317,10 +3335,82 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // SMTP configuration is // central-only and is never stored on a site (see the purge above). + // Site-secrets artifact deploy (Task 10): write the shipped secrets to the + // replicated deployed_secrets table, then reconcile THIS node's own mirror + // synchronously — the reply's SecretsApplied must reflect this node's own + // apply/verify outcome, not whatever the next periodic pass finds. A null + // command.Secrets means "central didn't ship secrets" (old-central + // compatible) and touches nothing; a non-null Secrets with no mirror + // registered on this node fails the deploy rather than silently applying + // nothing. + int? secretsApplied = null; + string? secretError = null; + if (command.Secrets != null) + { + if (_secretsMirror == null) + { + if (command.Secrets.Count > 0) + { + secretError = "Secret deployment is not enabled on this site node"; + } + else + { + secretsApplied = 0; + } + } + else + { + _logger.LogInformation( + "Applying {Count} deployed secret(s), deploymentId={DeploymentId}", + command.Secrets.Count, command.DeploymentId); + await _storage.UpsertDeployedSecretsAsync(command.Secrets, command.DeploymentId); + var mirror = await _secretsMirror.ReconcileAsync(CancellationToken.None); + + // The mirror reconciles the WHOLE site-wide deployed_secrets table + // every pass — not only the names THIS deploy just shipped — so a + // failure unrelated to this deploy's own secrets (another instance's + // already-deployed secret still failing, or the removal of a + // no-longer-referenced one) must never fail THIS response or show up + // in its ErrorMessage. Only failures among the SHIPPED names count. + var shippedNames = new HashSet( + command.Secrets.Select(s => s.Name), StringComparer.Ordinal); + var shippedFailureMessages = new List(); + var shippedFailedNames = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < mirror.FailedNames.Count; i++) + { + var failedName = mirror.FailedNames[i]; + var failedMessage = mirror.Failed[i]; + if (shippedNames.Contains(failedName)) + { + shippedFailedNames.Add(failedName); + shippedFailureMessages.Add(failedMessage); + } + else + { + _logger.LogWarning( + "Deployed secrets mirror reported a failure unrelated to " + + "this deploy ({DeploymentId}): {Failure}", + command.DeploymentId, failedMessage); + } + } + + // Never negative (ruling): shippedFailedNames is a set intersected + // against the shipped names, so this can only go below zero if the + // shipped list itself contains duplicate names. + secretsApplied = Math.Max(0, command.Secrets.Count - shippedFailedNames.Count); + if (shippedFailureMessages.Count > 0) + { + secretError = string.Join("; ", shippedFailureMessages); + } + } + } + // Replicate artifacts to standby node return new ArtifactDeploymentResponse( - command.DeploymentId, "", true, null, DateTimeOffset.UtcNow); + command.DeploymentId, "", secretError is null, secretError, DateTimeOffset.UtcNow, + secretsApplied); } catch (Exception ex) { diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs index 5e97688a..407ce58a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs @@ -17,8 +17,20 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets; /// Names applied (inserted or updated) this pass. /// Names deleted this pass because they were previously mirrored and are now tombstoned upstream. /// One message per live secret that failed to apply and/or decrypt on this node — never includes secret values. +/// +/// The secret name behind each entry, in the SAME order (index-for- +/// index — each pass reports at most one failure per row, so the two lists always stay in +/// lockstep). Added for DeploymentManagerActor.HandleDeployArtifacts (Task 10): this +/// mirror reconciles the WHOLE site-wide deployed_secrets table every pass, not only the +/// names a particular deploy just shipped, so a caller that needs to know whether a failure +/// belongs to ITS OWN shipped names — as opposed to some other, previously-deployed secret, or a +/// removal of a now-unreferenced one — cannot recover that from the message strings alone. +/// public sealed record MirrorResult( - IReadOnlyList Applied, IReadOnlyList Removed, IReadOnlyList Failed) + IReadOnlyList Applied, + IReadOnlyList Removed, + IReadOnlyList Failed, + IReadOnlyList FailedNames) { /// Whether this pass applied or removed anything (used to decide the summary log level). public bool Changed => Applied.Count > 0 || Removed.Count > 0; @@ -209,6 +221,7 @@ public sealed class DeployedSecretsMirror : BackgroundService List applied = []; List removed = []; List failed = []; + List failedNames = []; foreach (var row in rows) { @@ -220,7 +233,7 @@ public sealed class DeployedSecretsMirror : BackgroundService } else { - await ProcessLiveRowAsync(row, mirrored, applied, failed, ct).ConfigureAwait(false); + await ProcessLiveRowAsync(row, mirrored, applied, failed, failedNames, ct).ConfigureAwait(false); } } catch (Exception ex) when (!ct.IsCancellationRequested) @@ -229,11 +242,12 @@ public sealed class DeployedSecretsMirror : BackgroundService row.Name, $"error:{row.Secret.Revision}:{ex.GetType().Name}:{ex.Message}", $"Secret '{row.Name}' failed to process on this node ({ex.GetType().Name}: {ex.Message})", - failed); + failed, + failedNames); } } - var result = new MirrorResult(applied, removed, failed); + var result = new MirrorResult(applied, removed, failed, failedNames); if (result.Changed) { @@ -290,6 +304,7 @@ public sealed class DeployedSecretsMirror : BackgroundService Dictionary mirrored, List applied, List failed, + List failedNames, CancellationToken ct) { var name = new SecretName(row.Name); @@ -323,7 +338,8 @@ public sealed class DeployedSecretsMirror : BackgroundService $"outofband:{row.Secret.Revision}", $"Secret '{row.Name}' exists on this node with a newer local value; " + "the deployed revision was not applied", - failed); + failed, + failedNames); return; // Do not verify — that would decrypt the operator's own value. case ApplyOutcome.Unconfirmed: @@ -332,12 +348,13 @@ public sealed class DeployedSecretsMirror : BackgroundService $"unconfirmed:{row.Secret.Revision}", $"Secret '{row.Name}' could not be confirmed applied on this node " + "after a re-apply attempt", - failed); + failed, + failedNames); return; } } - await VerifyAsync(row, failed, ct).ConfigureAwait(false); + await VerifyAsync(row, failed, failedNames, ct).ConfigureAwait(false); } /// The confirmed outcome of applying one shipped row into this node's store. See the class remarks. @@ -419,7 +436,8 @@ public sealed class DeployedSecretsMirror : BackgroundService /// verify it decrypts on this node. On failure, appends a message to /// and logs with the shared de-spam rule (see the class remarks). /// - private async Task VerifyAsync(DeployedSecretRow row, List failed, CancellationToken ct) + private async Task VerifyAsync( + DeployedSecretRow row, List failed, List failedNames, CancellationToken ct) { bool resolved; try @@ -444,17 +462,20 @@ public sealed class DeployedSecretsMirror : BackgroundService var message = $"Secret '{row.Name}' (kek {row.Secret.KekId}) did not decrypt on this node — " + "the node's master key differs from central's"; - ReportFailure(row.Name, $"decrypt:{row.Secret.Revision}:{row.Secret.KekId}", message, failed); + ReportFailure(row.Name, $"decrypt:{row.Secret.Revision}:{row.Secret.KekId}", message, failed, failedNames); } /// - /// Appends to and logs it — Error the - /// first time is seen for since it - /// last succeeded, Debug on every identical repeat. + /// Appends to (and + /// to , in lockstep) and logs it — Error the first time + /// is seen for since it last + /// succeeded, Debug on every identical repeat. /// - private void ReportFailure(string name, string fingerprint, string message, List failed) + private void ReportFailure( + string name, string fingerprint, string message, List failed, List failedNames) { failed.Add(message); + failedNames.Add(name); if (_lastLoggedFailure.TryGetValue(name, out var previous) && previous == fingerprint) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs new file mode 100644 index 00000000..cf705226 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs @@ -0,0 +1,329 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets; +using ZB.MOM.WW.ScadaBridge.TestSupport; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Task 10, site-secrets artifact deploy: +/// writes shipped secrets to deployed_secrets, runs +/// synchronously, and answers with or a failure. +/// Follows the shape of the existing artifact-apply tests in DeploymentManagerActorTests. +/// +public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly TestLocalDb _localDb; + + public DeploymentManagerActorArtifactSecretsTests() + { + _localDb = TestLocalDb.CreateTemp("dm-artifact-secrets-test"); + _storage = new SiteStorageService( + _localDb.Db, + NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + void IDisposable.Dispose() + { + Shutdown(); + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + private IActorRef CreateDeploymentManager(DeployedSecretsMirror? secretsMirror = null) + { + var options = new SiteRuntimeOptions(); + // Positional, not named, past the constructor's first six parameters: a named + // argument for a trailing optional parameter (secretsMirror) that SKIPS other, + // still-optional parameters in between is rejected by the compiler inside an + // expression-tree lambda (Props.Create's Expression> overload) with + // CS9307 "named argument specification out of position". + return ActorOf(Props.Create(() => new DeploymentManagerActor( + _storage, + _compilationService, + _sharedScriptLibrary, + null, // streamManager: no stream manager in tests + options, + NullLogger.Instance, + null, // dclManager + null, // healthCollector + null, // serviceProvider + null, // loggerFactory + null, // configFetcher + null, // startupLoadRetryInterval + null, // configLoader + null, // externalSystemCache + null, // scriptScheduler + null, // connectionDefinitionLoader + null, // deployedConfigLoader + secretsMirror))); + } + + private DeployedSecretsMirror CreateMirror(FakeSecretStore store, FakeSecretResolver resolver) + { + var options = Options.Create(new SiteRuntimeOptions + { + DeployedSecretsMirrorInterval = TimeSpan.FromSeconds(15), + }); + return new DeployedSecretsMirror( + _storage, + store, + resolver, + invalidator: null, + options, + NullLogger.Instance, + TimeProvider.System); + } + + private static DeployArtifactsCommand ArtifactsCommand( + string deploymentId, IReadOnlyList? secrets) => new( + DeploymentId: deploymentId, + SharedScripts: null, + ExternalSystems: null, + DatabaseConnections: null, + NotificationLists: null, + DataConnections: null, + SmtpConfigurations: null, + Timestamp: DateTimeOffset.UtcNow, + Secrets: secrets); + + private static SecretArtifact Art(string name, long revision, string kek = "kek-1") => new( + Name: name, + ContentType: "Text", + Ciphertext: [1, 2, 3, 4, 5], + Nonce: [10, 11, 12], + Tag: [20, 21, 22, 23], + WrappedDek: [30, 31, 32, 33, 34, 35], + WrapNonce: [40, 41, 42], + WrapTag: [50, 51, 52, 53], + KekId: kek, + Revision: revision, + CreatedUtc: new DateTimeOffset(2026, 9, 28, 0, 0, 0, TimeSpan.Zero), + UpdatedUtc: new DateTimeOffset(2026, 9, 28, 1, 0, 0, TimeSpan.Zero), + Description: "test secret"); + + [Fact] + public async Task Secrets_Null_LeavesTableUntouched_AndSecretsAppliedNull() + { + var store = new FakeSecretStore(); + var resolver = new FakeSecretResolver(); + var mirror = CreateMirror(store, resolver); + var manager = CreateDeploymentManager(mirror); + + manager.Tell(ArtifactsCommand("dep-null", secrets: null)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + Assert.Null(response.SecretsApplied); + + var rows = await _storage.GetDeployedSecretsAsync(); + Assert.Empty(rows); + } + + [Fact] + public async Task Secrets_Present_FullSetWrite_SyncReconcile_SecretsAppliedEqualsShippedCount() + { + var store = new FakeSecretStore(); + var resolver = new FakeSecretResolver(); + var mirror = CreateMirror(store, resolver); + var manager = CreateDeploymentManager(mirror); + + manager.Tell(ArtifactsCommand("dep-2secrets", secrets: [Art("a", 1), Art("b", 1)])); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + Assert.Equal(2, response.SecretsApplied); + Assert.Null(response.ErrorMessage); + + // Reconcile ran synchronously before the reply — both secrets already applied. + Assert.True(store.Contains("a")); + Assert.True(store.Contains("b")); + } + + [Fact] + public async Task Secrets_Empty_WithMirror_SecretsAppliedZero_Success() + { + var store = new FakeSecretStore(); + var resolver = new FakeSecretResolver(); + var mirror = CreateMirror(store, resolver); + var manager = CreateDeploymentManager(mirror); + + manager.Tell(ArtifactsCommand("dep-empty", secrets: [])); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + Assert.Equal(0, response.SecretsApplied); + } + + [Fact] + public async Task Secrets_MirrorFailsOne_SuccessFalse_KekIdInMessage_SecretsAppliedExcludesFailure() + { + var store = new FakeSecretStore(); + var resolver = new FakeSecretResolver(); + resolver.ThrowFor("bad"); + var mirror = CreateMirror(store, resolver); + var manager = CreateDeploymentManager(mirror); + + manager.Tell(ArtifactsCommand( + "dep-onefails", secrets: [Art("good", 1), Art("bad", 1, kek: "kek-central")])); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(response.Success); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("kek-central", response.ErrorMessage); + Assert.Contains("'bad'", response.ErrorMessage); + Assert.Equal(1, response.SecretsApplied); // "good" applied+verified; "bad" failed + } + + [Fact] + public async Task Secrets_NonEmpty_NoMirrorRegistered_FailsDeploy() + { + var manager = CreateDeploymentManager(secretsMirror: null); + + manager.Tell(ArtifactsCommand("dep-nomirror", secrets: [Art("a", 1)])); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(response.Success); + Assert.Equal("Secret deployment is not enabled on this site node", response.ErrorMessage); + } + + [Fact] + public async Task Secrets_Empty_NoMirrorRegistered_TreatedAsZeroSuccess() + { + var manager = CreateDeploymentManager(secretsMirror: null); + + manager.Tell(ArtifactsCommand("dep-nomirror-empty", secrets: [])); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + Assert.Equal(0, response.SecretsApplied); + } + + [Fact] + public async Task Secrets_RemovalFailure_DoesNotFailDeploy_AndExcludedFromErrorMessage() + { + // A row that was previously mirrored (ledgered) but is now tombstoned upstream, and + // whose removal fails on this node's store — a failure UNRELATED to the names shipped + // by THIS deploy. Per ruling: log at Warning, never surface in ErrorMessage, never fail + // the deploy, never affect SecretsApplied (which only counts SHIPPED names). + var store = new FakeSecretStore(); + var resolver = new FakeSecretResolver(); + var mirror = CreateMirror(store, resolver); + var manager = CreateDeploymentManager(mirror); + + // First deploy ships + ledgers "gone". + manager.Tell(ArtifactsCommand("dep-r1", secrets: [Art("gone", 1)])); + var r1 = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(r1.Success); + Assert.True(store.Contains("gone")); + + // Make the store's delete fail on the next pass, then ship a deploy that no longer + // references "gone" (and ships an unrelated, healthy secret). + store.ThrowOnDelete = true; + manager.Tell(ArtifactsCommand("dep-r2", secrets: [Art("kept", 1)])); + var r2 = ExpectMsg(TimeSpan.FromSeconds(10)); + + Assert.True(r2.Success); + Assert.Equal(1, r2.SecretsApplied); // only "kept" is shipped by this deploy + Assert.Null(r2.ErrorMessage); + } + + // ── Fakes (mirrors DeployedSecretsMirrorTests' fakes; kept local/minimal per-task) ── + + private sealed class FakeSecretStore : ISecretStore + { + private readonly Dictionary _rows = new(StringComparer.Ordinal); + + public bool ThrowOnDelete { get; set; } + + public bool Contains(string name) => _rows.TryGetValue(name, out var row) && !row.IsDeleted; + + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult(_rows.TryGetValue(name.Value, out var row) ? row : null); + + public Task UpsertAsync(StoredSecret row, CancellationToken ct) + { + _rows[row.Name.Value] = row; + return Task.CompletedTask; + } + + public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) + { + if (ThrowOnDelete) + { + throw new InvalidOperationException("simulated delete fault"); + } + + if (!_rows.TryGetValue(name.Value, out var existing) || existing.IsDeleted) + { + return Task.FromResult(false); + } + + _rows[name.Value] = existing with + { + IsDeleted = true, + DeletedUtc = DateTimeOffset.UtcNow, + UpdatedUtc = DateTimeOffset.UtcNow, + Revision = existing.Revision + 1, + UpdatedBy = actor, + }; + return Task.FromResult(true); + } + + public Task> ListAsync(bool includeDeleted, CancellationToken ct) => + Task.FromResult>([]); + + public Task> GetManifestAsync(CancellationToken ct) => + Task.FromResult>([]); + + public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) + { + if (_rows.TryGetValue(row.Name.Value, out var existing) + && !SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision)) + { + return Task.CompletedTask; + } + + _rows[row.Name.Value] = row; + return Task.CompletedTask; + } + + public Task ApplyRewrapAsync( + StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => + Task.FromResult(false); + } + + private sealed class FakeSecretResolver : ISecretResolver + { + private readonly HashSet _throwing = new(StringComparer.Ordinal); + + public void ThrowFor(string name) => _throwing.Add(name); + + public Task GetAsync(SecretName name, CancellationToken ct) + { + if (_throwing.Contains(name.Value)) + { + throw new InvalidOperationException("simulated decrypt failure"); + } + + return Task.FromResult("decrypted-value"); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs index 3486eac8..0209d552 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs @@ -333,6 +333,9 @@ public class DeployedSecretsMirrorTests : IDisposable var message = Assert.Single(r.Failed); Assert.Contains("kek-central", message); Assert.Contains("'a'", message); + // FailedNames (Task 10): the name behind each Failed entry, in lockstep order, so a + // caller (DeploymentManagerActor) can tell whether a failure belongs to names it shipped. + Assert.Equal(["a"], r.FailedNames); } [Fact]