using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// is the central half of design §3.1/§3.3: derive which /// secrets a site's data connections reference, resolve each against the central /// , and report the ones that could not be resolved. It never sees /// or produces plaintext — everything here is ciphertext bytes. /// public class SiteSecretsCollectorTests { private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null) => new(name, "MxGateway", primary, backup, 3); private static StoredSecret Row(string name, long revision, bool deleted = false, string? description = null) => new() { Name = new SecretName(name), Description = description, ContentType = SecretContentType.Text, Ciphertext = [1, 2, 3], Nonce = [4, 5], Tag = [6, 7], WrappedDek = [8, 9], WrapNonce = [10, 11], WrapTag = [12, 13], KekId = "kek-1", Revision = revision, IsDeleted = deleted, CreatedUtc = DateTimeOffset.Parse("2026-01-01T00:00:00Z"), UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"), }; /// Verifies that collect returns only referenced rows and names missing. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_ReturnsOnlyReferencedRows_AndNamesMissing() { var store = new FakeSecretStore(Row("a", 3), Row("b", 1, deleted: true), Row("other", 1)); var result = await new SiteSecretsCollector(store).CollectAsync( [Dc("GW1", """{"ApiKey":"secret:a"}"""), Dc("GW2", """{"ApiKey":"secret:b"}""")], default); // Only the referenced names come back — "other" is in the store but never referenced. Assert.Equal(["a"], result.Secrets.Select(s => s.Name)); Assert.Equal(3, result.Secrets[0].Revision); // "b" is tombstoned (IsDeleted) — treated the same as absent. Assert.Equal(["b"], result.Missing.Select(m => m.Name)); Assert.Equal(["GW2"], result.Missing[0].ReferencedBy); } /// Verifies that collect resolved row maps every field from the stored secret verbatim. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_ResolvedRow_MapsEveryFieldFromTheStoredSecretVerbatim() { var row = Row("mxgw-key", revision: 7, description: "MxGateway API key"); var store = new FakeSecretStore(row); var result = await new SiteSecretsCollector(store).CollectAsync( [Dc("GW1", """{"ApiKey":"secret:mxgw-key"}""")], default); var artifact = Assert.Single(result.Secrets); Assert.Empty(result.Missing); Assert.Equal(row.Name.Value, artifact.Name); Assert.Equal(row.ContentType.ToString(), artifact.ContentType); Assert.Equal(row.Ciphertext, artifact.Ciphertext); Assert.Equal(row.Nonce, artifact.Nonce); Assert.Equal(row.Tag, artifact.Tag); Assert.Equal(row.WrappedDek, artifact.WrappedDek); Assert.Equal(row.WrapNonce, artifact.WrapNonce); Assert.Equal(row.WrapTag, artifact.WrapTag); Assert.Equal(row.KekId, artifact.KekId); Assert.Equal(row.Revision, artifact.Revision); Assert.Equal(row.CreatedUtc, artifact.CreatedUtc); Assert.Equal(row.UpdatedUtc, artifact.UpdatedUtc); Assert.Equal(row.Description, artifact.Description); } /// Verifies that collect absent name is reported missing not thrown. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_AbsentName_IsReportedMissing_NotThrown() { var store = new FakeSecretStore(); var result = await new SiteSecretsCollector(store).CollectAsync( [Dc("GW1", """{"ApiKey":"secret:never-created"}""")], default); Assert.Empty(result.Secrets); var missing = Assert.Single(result.Missing); Assert.Equal("never-created", missing.Name); Assert.Equal(["GW1"], missing.ReferencedBy); } /// Verifies that collect no secret references returns empty without querying the store. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_NoSecretReferences_ReturnsEmptyWithoutQueryingTheStore() { var store = new FakeSecretStore(Row("unused", 1)); var result = await new SiteSecretsCollector(store).CollectAsync( [Dc("GW1", """{"Endpoint":"http://gw:5000"}""")], default); Assert.Empty(result.Secrets); Assert.Empty(result.Missing); } /// Verifies that collect store throws returns store error instead of throwing. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_StoreThrows_ReturnsStoreError_InsteadOfThrowing() { // One site's store read failing must not abort a whole sweep. var store = new FakeSecretStore(Row("a", 1)) { Fault = new InvalidOperationException("db down") }; var result = await new SiteSecretsCollector(store).CollectAsync( [Dc("GW1", """{"ApiKey":"secret:a"}""")], default); Assert.NotNull(result.StoreError); Assert.Contains("'a'", result.StoreError); Assert.Contains("InvalidOperationException: db down", result.StoreError); } /// Verifies that collect caller cancellation still propagates. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_CallerCancellation_StillPropagates() { using var cts = new CancellationTokenSource(); cts.Cancel(); var store = new FakeSecretStore(Row("a", 1)) { Fault = new OperationCanceledException(cts.Token) }; await Assert.ThrowsAnyAsync(() => new SiteSecretsCollector(store).CollectAsync( [Dc("GW1", """{"ApiKey":"secret:a"}""")], cts.Token)); } /// Verifies that collect success has no store error. /// A task that represents the asynchronous operation. [Fact] public async Task Collect_Success_HasNoStoreError() { var result = await new SiteSecretsCollector(new FakeSecretStore(Row("a", 1))).CollectAsync( [Dc("GW1", """{"ApiKey":"secret:a"}""")], default); Assert.Null(result.StoreError); } /// /// In-memory stand-in. Implements only — /// the sole member calls; every other member throws so an /// accidental call (a write, a list, a replication apply) fails the test loudly instead of /// silently no-op'ing. /// private sealed class FakeSecretStore : ISecretStore { private readonly Dictionary _rows; /// Initializes a new instance of FakeSecretStore. /// The rows. public FakeSecretStore(params StoredSecret[] rows) => _rows = rows.ToDictionary(r => r.Name); /// When set, throws this instead of reading. public Exception? Fault { get; init; } /// Returns the canned secret row, or faults when a fault is configured. /// The secret name to look up. /// A token to cancel the operation. /// A task that yields the stored secret, or null. public Task GetAsync(SecretName name, CancellationToken ct) => Fault is not null ? Task.FromException(Fault) : Task.FromResult(_rows.TryGetValue(name, out var row) ? row : null); /// Not supported by this fake. /// The secret row, unused. /// A token to cancel the operation. /// A task that represents the asynchronous operation. public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); /// Not supported by this fake. /// The secret name, unused. /// The acting user, unused. /// A token to cancel the operation. /// A task that yields whether a row was deleted. public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException(); /// Not supported by this fake. /// Whether tombstoned secrets are included. /// A token to cancel the operation. /// A task that yields the secret metadata. public Task> ListAsync(bool includeDeleted, CancellationToken ct) => throw new NotSupportedException(); /// Not supported by this fake. /// A token to cancel the operation. /// A task that yields the secret manifest. public Task> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException(); /// Not supported by this fake. /// The replicated secret row, unused. /// A token to cancel the operation. /// A task that represents the asynchronous operation. public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); /// Not supported by this fake. /// The rewrapped secret row, unused. /// The wrapped data key expected to be current, unused. /// A token to cancel the operation. /// A task that yields whether the rewrap was applied. public Task ApplyRewrapAsync( StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException(); } }