using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets; using ZB.MOM.WW.ScadaBridge.TestSupport; using ZB.MOM.WW.Secrets; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Crypto; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets; /// /// Runs /// against the REAL ZB.MOM.WW.Secrets stack — /// + + /// over a real temp-file SQLite database — instead of the /// in-memory fakes uses. /// /// /// 's fakes never actually decrypt anything, so the /// class remarks' claim that a tombstone-recovery re-stamp (bumping UpdatedUtc/ /// Revision without touching ciphertext) cannot break decryption was previously proven /// only by reading AesGcmEnvelopeCipher's source (its AAD binds only /// and , never /// Revision/UpdatedUtc). This test pins that property against the real cipher, so /// a future library version that starts binding either field into the AEAD associated data /// would fail here instead of only being caught by re-reading the source. /// public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDisposable { private readonly string _secretsDbPath = Path.Combine(Path.GetTempPath(), $"zb-secrets-real-{Guid.NewGuid():N}.db"); private readonly TestLocalDb _localDb = TestLocalDb.CreateTemp("deployed-secrets-mirror-real"); private SqliteSecretStore _store = null!; private AesGcmEnvelopeCipher _cipher = null!; private DefaultSecretResolver _resolver = null!; private SiteStorageService _storage = null!; /// Prepares the test fixture before the first test runs. /// A task that represents the asynchronous operation. public async Task InitializeAsync() { var connectionFactory = new SecretsSqliteConnectionFactory(_secretsDbPath); await new SqliteSecretsStoreMigrator(connectionFactory).MigrateAsync(CancellationToken.None); _store = new SqliteSecretStore(connectionFactory); var masterKey = new FakeMasterKeyProvider("kek-central"); _cipher = new AesGcmEnvelopeCipher(masterKey); // DefaultSecretResolver implements BOTH ISecretResolver and ISecretCacheInvalidator (the // real production shape — see SecretsServiceCollectionExtensions, which registers the // same singleton instance behind both interfaces), so this test also exercises real // cache invalidation, not a fake one. _resolver = new DefaultSecretResolver(_store, _cipher, NoOpAuditWriter.Instance, TimeSpan.FromSeconds(30)); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); await _storage.InitializeAsync(); } /// Does nothing; cleanup happens in Dispose. /// A task that represents the asynchronous operation. public Task DisposeAsync() => Task.CompletedTask; /// Releases the resources held by this instance. public void Dispose() { var path = _localDb.Path; _localDb.Dispose(); TestLocalDb.DeleteFiles(path); foreach (var suffix in new[] { "", "-wal", "-shm" }) { try { File.Delete(_secretsDbPath + suffix); } catch { /* best effort */ } } } /// Verifies that against the real secrets stack an encrypt, apply, delete and re-add cycle yields a recovered row that resolves to the original plaintext. /// A task that represents the asynchronous operation. [Fact] public async Task RealStack_EncryptApplyDeleteReAdd_TheRecoveredRowResolvesToTheOriginalPlaintext() { const string plaintext = "s3cr3t-value"; // "Central" encrypts the secret under the fleet's shared KEK (the same KekId this // node's own master key provider reports, mirroring a real shared-KEK fleet) and ships // it as a SecretArtifact — the exact wire shape DeployedSecretsCollector produces. var sealedSecret = _cipher.Encrypt(new SecretName("a"), plaintext, SecretContentType.Text); // Deliberately far in the past so DeleteAsync's real-wall-clock tombstone stamp is // guaranteed strictly newer, regardless of when this test runs — the point being // tested is the RECOVERY, not a race against the tombstone. var shippedUtc = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); var artifact = new SecretArtifact( Name: "a", ContentType: SecretContentType.Text.ToString(), Ciphertext: sealedSecret.Ciphertext, Nonce: sealedSecret.Nonce, Tag: sealedSecret.Tag, WrappedDek: sealedSecret.WrappedDek, WrapNonce: sealedSecret.WrapNonce, WrapTag: sealedSecret.WrapTag, KekId: sealedSecret.KekId, Revision: 1, CreatedUtc: shippedUtc, UpdatedUtc: shippedUtc); var mirror = new DeployedSecretsMirror( _storage, _store, _resolver, _resolver, // the same singleton also serves ISecretCacheInvalidator Options.Create(new SiteRuntimeOptions()), NullLogger.Instance, TimeProvider.System); // Apply — a fresh name, so this is a plain (non-recovery) apply. await _storage.UpsertDeployedSecretsAsync([artifact], "d1"); var applyPass = await mirror.ReconcileAsync(CancellationToken.None); Assert.Equal(["a"], applyPass.Applied); Assert.Empty(applyPass.Failed); Assert.Equal(plaintext, await _resolver.GetAsync(new SecretName("a"), CancellationToken.None)); // Delete — the site stops referencing it; the REAL store tombstones (does not // hard-delete) at DateTimeOffset.UtcNow, which is far newer than the 2000-01-01 shipped // stamp above. await _storage.UpsertDeployedSecretsAsync([], "d2"); var removePass = await mirror.ReconcileAsync(CancellationToken.None); Assert.Equal(["a"], removePass.Removed); Assert.Null(await _resolver.GetAsync(new SecretName("a"), CancellationToken.None)); // Re-add the SAME (unchanged, still 2000-01-01-stamped) artifact. A naive // ApplyReplicatedAsync call is a no-op against the newer tombstone — this forces the // mirror's tombstone-recovery re-stamp path against the REAL store and REAL cipher. await _storage.UpsertDeployedSecretsAsync([artifact], "d3"); var reAddPass = await mirror.ReconcileAsync(CancellationToken.None); Assert.Equal(["a"], reAddPass.Applied); Assert.Empty(reAddPass.Failed); // The recovered row must decrypt to the ORIGINAL plaintext through the real // AesGcmEnvelopeCipher, proving the bumped UpdatedUtc/Revision are not — and cannot // silently become — bound into the AEAD associated data. var resolved = await _resolver.GetAsync(new SecretName("a"), CancellationToken.None); Assert.Equal(plaintext, resolved); var recovered = await _store.GetAsync(new SecretName("a"), CancellationToken.None); Assert.NotNull(recovered); Assert.False(recovered!.IsDeleted); Assert.True(recovered.UpdatedUtc > shippedUtc); // genuinely re-stamped, not a no-op } /// /// A hand-written test double, mirroring the library's own /// (test-internal, not shipped in the package) FakeMasterKeyProvider. /// private sealed class FakeMasterKeyProvider : IMasterKeyProvider { private readonly byte[] _key = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); /// Initializes the provider with a key encryption key id. /// The key encryption key id reported by the provider. public FakeMasterKeyProvider(string kekId) => KekId = kekId; /// Gets the key encryption key id. public string KekId { get; } /// Returns the fixed master key. /// The master key bytes. public ReadOnlyMemory GetMasterKey() => _key; } }