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.
410 lines
18 KiB
C#
410 lines
18 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
|
|
|
|
/// <summary>
|
|
/// Site-secrets artifact deploy: site-local <c>deployed_secrets</c>
|
|
/// (LocalDb-replicated ciphertext) and <c>mirrored_secrets</c> (node-local ledger) stores.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Backed by a real temp-file LocalDb: <see cref="SiteStorageService"/> takes an
|
|
/// <c>ILocalDb</c> rather than a connection string, and LocalDb has no in-memory mode.
|
|
/// </remarks>
|
|
public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
|
|
{
|
|
private readonly TestLocalDb _localDb;
|
|
private SiteStorageService _storage = null!;
|
|
|
|
/// <summary>Initializes the test class and creates the backing storage.</summary>
|
|
public DeployedSecretsStorageTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("deployed-secrets");
|
|
}
|
|
|
|
/// <summary>Prepares the fixture before the tests run.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task InitializeAsync()
|
|
{
|
|
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
|
await _storage.InitializeAsync();
|
|
}
|
|
|
|
/// <summary>Releases the resources used by this instance asynchronously.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
/// <summary>Releases the resources used by this instance.</summary>
|
|
public void Dispose()
|
|
{
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
// ── deployed_secrets: full-set upsert / soft-delete reconciliation ──
|
|
|
|
/// <summary>Verifies that an upsert is treated as the full set and marks absent rows as removed.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Upsert_IsFullSet_MarksAbsentRowsRemoved()
|
|
{
|
|
var s = _storage;
|
|
await s.UpsertDeployedSecretsAsync([Art("A", 1), Art("B", 1)], "d1");
|
|
var (applied, removed) = await s.UpsertDeployedSecretsAsync([Art("A", 2)], "d2");
|
|
|
|
Assert.Equal(["A"], applied);
|
|
Assert.Equal(["B"], removed);
|
|
|
|
var rows = await s.GetDeployedSecretsAsync();
|
|
Assert.Equal(2, rows.Single(r => r.Name == "A").Secret.Revision);
|
|
Assert.True(rows.Single(r => r.Name == "B").IsRemoved);
|
|
}
|
|
|
|
/// <summary>Verifies that the first upsert applies every row and removes nothing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Upsert_FirstCall_AppliesEveryRow_AndRemovesNothing()
|
|
{
|
|
var (applied, removed) = await _storage.UpsertDeployedSecretsAsync(
|
|
[Art("A", 1), Art("B", 1)], "d1");
|
|
|
|
Assert.Equal(["A", "B"], applied);
|
|
Assert.Empty(removed);
|
|
}
|
|
|
|
/// <summary>Verifies that a removed row is not reported as removed again when the set is re-applied.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Upsert_ReAppliedRow_IsNotReportedAsRemovedTwice()
|
|
{
|
|
await _storage.UpsertDeployedSecretsAsync([Art("A", 1), Art("B", 1)], "d1");
|
|
var (_, removedFirst) = await _storage.UpsertDeployedSecretsAsync([Art("A", 2)], "d2");
|
|
Assert.Equal(["B"], removedFirst);
|
|
|
|
// B stays removed and is not re-reported as "newly" removed on a later call that
|
|
// still omits it.
|
|
var (_, removedSecond) = await _storage.UpsertDeployedSecretsAsync([Art("A", 3)], "d3");
|
|
Assert.Empty(removedSecond);
|
|
}
|
|
|
|
/// <summary>Verifies that a removed row can be re-applied and flips back to not removed.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Upsert_RowThatWasRemoved_CanBeReAppliedAndFlipsBackToNotRemoved()
|
|
{
|
|
await _storage.UpsertDeployedSecretsAsync([Art("A", 1), Art("B", 1)], "d1");
|
|
await _storage.UpsertDeployedSecretsAsync([Art("A", 1)], "d2"); // B removed
|
|
|
|
var (applied, removed) = await _storage.UpsertDeployedSecretsAsync(
|
|
[Art("A", 1), Art("B", 2)], "d3");
|
|
|
|
Assert.Equal(["A", "B"], applied);
|
|
Assert.Empty(removed);
|
|
|
|
var rows = await _storage.GetDeployedSecretsAsync();
|
|
var b = rows.Single(r => r.Name == "B");
|
|
Assert.False(b.IsRemoved);
|
|
Assert.Equal(2, b.Secret.Revision);
|
|
}
|
|
|
|
/// <summary>Verifies that upserting an empty set marks every existing row as removed.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Upsert_EmptySet_MarksEveryExistingRowRemoved()
|
|
{
|
|
await _storage.UpsertDeployedSecretsAsync([Art("A", 1), Art("B", 1)], "d1");
|
|
|
|
var (applied, removed) = await _storage.UpsertDeployedSecretsAsync([], "d2");
|
|
|
|
Assert.Empty(applied);
|
|
Assert.Equal(["A", "B"], removed.OrderBy(n => n, StringComparer.Ordinal));
|
|
|
|
var rows = await _storage.GetDeployedSecretsAsync();
|
|
Assert.All(rows, r => Assert.True(r.IsRemoved));
|
|
}
|
|
|
|
/// <summary>Verifies that reading the deployed secrets round-trips every field of the secret artifact.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetDeployedSecretsAsync_RoundTripsEveryFieldOfTheSecretArtifact()
|
|
{
|
|
var art = Art("Full", 7);
|
|
await _storage.UpsertDeployedSecretsAsync([art], "dep-full");
|
|
|
|
var row = Assert.Single(await _storage.GetDeployedSecretsAsync());
|
|
|
|
Assert.Equal("Full", row.Name);
|
|
Assert.Equal("dep-full", row.DeploymentId);
|
|
Assert.False(row.IsRemoved);
|
|
Assert.Equal(art.Name, row.Secret.Name);
|
|
Assert.Equal(art.ContentType, row.Secret.ContentType);
|
|
Assert.Equal(art.Ciphertext, row.Secret.Ciphertext);
|
|
Assert.Equal(art.Nonce, row.Secret.Nonce);
|
|
Assert.Equal(art.Tag, row.Secret.Tag);
|
|
Assert.Equal(art.WrappedDek, row.Secret.WrappedDek);
|
|
Assert.Equal(art.WrapNonce, row.Secret.WrapNonce);
|
|
Assert.Equal(art.WrapTag, row.Secret.WrapTag);
|
|
Assert.Equal(art.KekId, row.Secret.KekId);
|
|
Assert.Equal(art.Revision, row.Secret.Revision);
|
|
Assert.Equal(art.CreatedUtc, row.Secret.CreatedUtc);
|
|
Assert.Equal(art.UpdatedUtc, row.Secret.UpdatedUtc);
|
|
Assert.Equal(art.Description, row.Secret.Description);
|
|
}
|
|
|
|
/// <summary>Verifies that reading the deployed secrets includes rows marked as removed.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetDeployedSecretsAsync_IncludesRemovedRows()
|
|
{
|
|
await _storage.UpsertDeployedSecretsAsync([Art("A", 1)], "d1");
|
|
await _storage.UpsertDeployedSecretsAsync([], "d2"); // A removed
|
|
|
|
var rows = await _storage.GetDeployedSecretsAsync();
|
|
|
|
var a = Assert.Single(rows);
|
|
Assert.Equal("A", a.Name);
|
|
Assert.True(a.IsRemoved);
|
|
}
|
|
|
|
// ── security: row_json holds only base64 ciphertext, never plaintext ──
|
|
|
|
/// <summary>Verifies that the stored row JSON deserializes to a secret artifact with no extra properties.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RowJson_DeserializesToSecretArtifact_WithNoExtraProperties()
|
|
{
|
|
// Guards against ever widening SecretArtifact (or row_json) with a plaintext
|
|
// field: this walks the RAW stored JSON, not the deserialized object, so a
|
|
// stray extra property would be caught here even though JsonSerializer.Deserialize
|
|
// silently ignores unknown members by default.
|
|
var art = Art("Sec1", 3);
|
|
await _storage.UpsertDeployedSecretsAsync([art], "dep-sec");
|
|
|
|
var rowJson = await ReadRawRowJsonAsync("Sec1");
|
|
|
|
// SecretArtifact is a record, but records only get member-wise Equals for reference
|
|
// types via THEIR OWN Equals — and Array does not override Equals, so byte[] members
|
|
// compare by reference. Assert.Equal(art, roundTripped) would therefore fail even on
|
|
// a byte-for-byte-identical round trip; compare field-by-field instead.
|
|
var roundTripped = JsonSerializer.Deserialize<SecretArtifact>(rowJson);
|
|
Assert.NotNull(roundTripped);
|
|
Assert.Equal(art.Name, roundTripped!.Name);
|
|
Assert.Equal(art.ContentType, roundTripped.ContentType);
|
|
Assert.Equal(art.Ciphertext, roundTripped.Ciphertext);
|
|
Assert.Equal(art.Nonce, roundTripped.Nonce);
|
|
Assert.Equal(art.Tag, roundTripped.Tag);
|
|
Assert.Equal(art.WrappedDek, roundTripped.WrappedDek);
|
|
Assert.Equal(art.WrapNonce, roundTripped.WrapNonce);
|
|
Assert.Equal(art.WrapTag, roundTripped.WrapTag);
|
|
Assert.Equal(art.KekId, roundTripped.KekId);
|
|
Assert.Equal(art.Revision, roundTripped.Revision);
|
|
Assert.Equal(art.CreatedUtc, roundTripped.CreatedUtc);
|
|
Assert.Equal(art.UpdatedUtc, roundTripped.UpdatedUtc);
|
|
Assert.Equal(art.Description, roundTripped.Description);
|
|
|
|
using var document = JsonDocument.Parse(rowJson);
|
|
var propertyNames = document.RootElement.EnumerateObject()
|
|
.Select(p => p.Name)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
nameof(SecretArtifact.Name), nameof(SecretArtifact.ContentType),
|
|
nameof(SecretArtifact.Ciphertext), nameof(SecretArtifact.Nonce),
|
|
nameof(SecretArtifact.Tag), nameof(SecretArtifact.WrappedDek),
|
|
nameof(SecretArtifact.WrapNonce), nameof(SecretArtifact.WrapTag),
|
|
nameof(SecretArtifact.KekId), nameof(SecretArtifact.Revision),
|
|
nameof(SecretArtifact.CreatedUtc), nameof(SecretArtifact.UpdatedUtc),
|
|
nameof(SecretArtifact.Description),
|
|
};
|
|
|
|
Assert.Equal(expected, propertyNames);
|
|
}
|
|
|
|
/// <summary>Verifies that the stored row JSON encodes every byte-array field as Base64 text.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RowJson_EncodesEveryByteArrayField_AsBase64Text()
|
|
{
|
|
// byte[] serializes as base64 via System.Text.Json by default. Asserted at the raw
|
|
// JSON level (JsonValueKind.String, not an array of numbers) so a future custom
|
|
// converter that changed the wire shape would be caught here.
|
|
var art = Art("Sec2", 1);
|
|
await _storage.UpsertDeployedSecretsAsync([art], "dep-sec2");
|
|
|
|
var rowJson = await ReadRawRowJsonAsync("Sec2");
|
|
using var document = JsonDocument.Parse(rowJson);
|
|
var root = document.RootElement;
|
|
|
|
foreach (var byteArrayField in new[]
|
|
{
|
|
nameof(SecretArtifact.Ciphertext), nameof(SecretArtifact.Nonce),
|
|
nameof(SecretArtifact.Tag), nameof(SecretArtifact.WrappedDek),
|
|
nameof(SecretArtifact.WrapNonce), nameof(SecretArtifact.WrapTag),
|
|
})
|
|
{
|
|
var property = root.GetProperty(byteArrayField);
|
|
Assert.Equal(JsonValueKind.String, property.ValueKind);
|
|
|
|
// Base64.TryFromBase64String throws on malformed input — proves it really is
|
|
// base64 text, not just any string.
|
|
Assert.True(Convert.TryFromBase64String(property.GetString()!, new byte[64], out _));
|
|
}
|
|
}
|
|
|
|
private async Task<string> ReadRawRowJsonAsync(string name)
|
|
{
|
|
await using var connection = _storage.CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "SELECT row_json FROM deployed_secrets WHERE name = @name";
|
|
command.Parameters.AddWithValue("@name", name);
|
|
return (string)(await command.ExecuteScalarAsync())!;
|
|
}
|
|
|
|
// ── mirrored_secrets: node-local ledger ──
|
|
|
|
/// <summary>Verifies that a mirrored secret that is set can be read back.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MirroredSecrets_SetThenGet_RoundTrips()
|
|
{
|
|
await _storage.SetMirroredSecretAsync("Sec1", 3);
|
|
|
|
var rows = await _storage.GetMirroredSecretsAsync();
|
|
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal("Sec1", row.Name);
|
|
Assert.Equal(3, row.Revision);
|
|
}
|
|
|
|
/// <summary>Verifies that setting a mirrored secret twice updates the existing entry.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MirroredSecrets_Set_IsUpsert()
|
|
{
|
|
await _storage.SetMirroredSecretAsync("Sec1", 1);
|
|
await _storage.SetMirroredSecretAsync("Sec1", 2);
|
|
|
|
var rows = await _storage.GetMirroredSecretsAsync();
|
|
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal(2, row.Revision);
|
|
}
|
|
|
|
/// <summary>Verifies that removing a mirrored secret deletes its ledger row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MirroredSecrets_Remove_DeletesTheLedgerRow()
|
|
{
|
|
await _storage.SetMirroredSecretAsync("Sec1", 1);
|
|
|
|
await _storage.RemoveMirroredSecretAsync("Sec1");
|
|
|
|
Assert.Empty(await _storage.GetMirroredSecretsAsync());
|
|
}
|
|
|
|
/// <summary>Verifies that removing an unknown mirrored secret name does nothing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MirroredSecrets_Remove_OfAnUnknownName_IsANoOp()
|
|
{
|
|
await _storage.RemoveMirroredSecretAsync("NeverMirrored");
|
|
|
|
Assert.Empty(await _storage.GetMirroredSecretsAsync());
|
|
}
|
|
|
|
/// <summary>Verifies that the mirrored-secrets ledger is independent of the deployed secrets.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MirroredSecrets_IsIndependentOf_DeployedSecrets()
|
|
{
|
|
// mirroring is a separate, node-local action from the site-wide deployed_secrets
|
|
// reconcile — removing a deployed secret must not touch the mirrored ledger, and
|
|
// vice versa. Mirroring/un-mirroring in response to a removal is the caller's job.
|
|
await _storage.UpsertDeployedSecretsAsync([Art("A", 1)], "d1");
|
|
await _storage.SetMirroredSecretAsync("A", 1);
|
|
|
|
await _storage.UpsertDeployedSecretsAsync([], "d2"); // A removed from deployed_secrets
|
|
|
|
var deployed = Assert.Single(await _storage.GetDeployedSecretsAsync());
|
|
Assert.True(deployed.IsRemoved);
|
|
|
|
var mirrored = Assert.Single(await _storage.GetMirroredSecretsAsync());
|
|
Assert.Equal("A", mirrored.Name);
|
|
}
|
|
|
|
// ── secret_mirror_status (standby mirror failures) ──
|
|
|
|
/// <summary>Verifies that mirror status round-trips with one row per node, ordered by node name.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SecretMirrorStatus_RoundTrips_OneRowPerNode_OrderedByNodeName()
|
|
{
|
|
var at = new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero);
|
|
await _storage.SetSecretMirrorStatusAsync(
|
|
"node-b", [new SecretMirrorFailure("A", "did not decrypt")], at);
|
|
await _storage.SetSecretMirrorStatusAsync("node-a", [], at.AddMinutes(1));
|
|
|
|
var rows = await _storage.GetSecretMirrorStatusesAsync();
|
|
|
|
Assert.Equal(["node-a", "node-b"], rows.Select(r => r.NodeName));
|
|
Assert.Empty(rows[0].Failures);
|
|
Assert.Equal(at.AddMinutes(1), rows[0].CheckedAtUtc);
|
|
var failure = Assert.Single(rows[1].Failures);
|
|
Assert.Equal(new SecretMirrorFailure("A", "did not decrypt"), failure);
|
|
Assert.Equal(at, rows[1].CheckedAtUtc);
|
|
}
|
|
|
|
/// <summary>Verifies that setting the mirror status updates that node's existing row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SecretMirrorStatus_Set_UpsertsTheNodesRow()
|
|
{
|
|
var at = new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero);
|
|
await _storage.SetSecretMirrorStatusAsync("node-a", [new SecretMirrorFailure("A", "x")], at);
|
|
await _storage.SetSecretMirrorStatusAsync("node-a", [], at.AddHours(1));
|
|
|
|
var row = Assert.Single(await _storage.GetSecretMirrorStatusesAsync());
|
|
Assert.Empty(row.Failures);
|
|
Assert.Equal(at.AddHours(1), row.CheckedAtUtc);
|
|
}
|
|
|
|
/// <summary>Verifies that an unparseable mirror status row reads as a failure rather than as healthy.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SecretMirrorStatus_UnparseableRow_ReadsAsAFailure_NotAsHealthy()
|
|
{
|
|
await using (var connection = _storage.CreateConnection())
|
|
await using (var insert = connection.CreateCommand())
|
|
{
|
|
insert.CommandText =
|
|
"INSERT INTO secret_mirror_status (node_name, failures_json, checked_at) VALUES ('node-b', '{not json', '2026-09-28T00:00:00.0000000+00:00')";
|
|
await insert.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
var row = Assert.Single(await _storage.GetSecretMirrorStatusesAsync());
|
|
Assert.Equal("node-b", row.NodeName);
|
|
Assert.Single(row.Failures);
|
|
}
|
|
|
|
private static SecretArtifact Art(string name, long revision) => new(
|
|
Name: name,
|
|
ContentType: "application/octet-stream",
|
|
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-1",
|
|
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");
|
|
}
|