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;
}
}