513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
172 lines
8.7 KiB
C#
172 lines
8.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Runs
|
|
/// <see cref="DeployedSecretsMirror"/> against the REAL <c>ZB.MOM.WW.Secrets</c> stack —
|
|
/// <see cref="SqliteSecretStore"/> + <see cref="AesGcmEnvelopeCipher"/> +
|
|
/// <see cref="DefaultSecretResolver"/> over a real temp-file SQLite database — instead of the
|
|
/// in-memory fakes <see cref="DeployedSecretsMirrorTests"/> uses.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="DeployedSecretsMirrorTests"/>'s fakes never actually decrypt anything, so the
|
|
/// class remarks' claim that a tombstone-recovery re-stamp (bumping <c>UpdatedUtc</c>/
|
|
/// <c>Revision</c> without touching ciphertext) cannot break decryption was previously proven
|
|
/// only by reading <c>AesGcmEnvelopeCipher</c>'s source (its AAD binds only
|
|
/// <see cref="SecretName.Value"/> and <see cref="StoredSecret.KekId"/>, never
|
|
/// <c>Revision</c>/<c>UpdatedUtc</c>). 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.
|
|
/// </remarks>
|
|
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!;
|
|
|
|
/// <summary>Prepares the test fixture before the first test runs.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
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<SiteStorageService>.Instance);
|
|
await _storage.InitializeAsync();
|
|
}
|
|
|
|
/// <summary>Does nothing; cleanup happens in <c>Dispose</c>.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
/// <summary>Releases the resources held by this instance.</summary>
|
|
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 */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<DeployedSecretsMirror>.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
|
|
}
|
|
|
|
/// <summary>
|
|
/// A hand-written <see cref="IMasterKeyProvider"/> test double, mirroring the library's own
|
|
/// (test-internal, not shipped in the package) <c>FakeMasterKeyProvider</c>.
|
|
/// </summary>
|
|
private sealed class FakeMasterKeyProvider : IMasterKeyProvider
|
|
{
|
|
private readonly byte[] _key = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
|
|
|
|
/// <summary>Initializes the provider with a key encryption key id.</summary>
|
|
/// <param name="kekId">The key encryption key id reported by the provider.</param>
|
|
public FakeMasterKeyProvider(string kekId) => KekId = kekId;
|
|
|
|
/// <summary>Gets the key encryption key id.</summary>
|
|
public string KekId { get; }
|
|
|
|
/// <summary>Returns the fixed master key.</summary>
|
|
/// <returns>The master key bytes.</returns>
|
|
public ReadOnlyMemory<byte> GetMasterKey() => _key;
|
|
}
|
|
}
|