diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs index bb8c125a..85231b19 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs @@ -48,14 +48,27 @@ namespace ZB.MOM.WW.ScadaBridge.Host; public static class SiteLocalDbSetup { /// - /// The ten tables that participate in replication — registered when this node has a peer, + /// The eleven tables that participate in replication — registered when this node has a peer, /// and deregistered (stale triggers dropped) when it does not. /// /// + /// /// notification_lists and smtp_configurations are created by the schema but /// deliberately absent here; see the rationale in . The list is shared by /// both branches on purpose: a table added to one and not the other would either register /// without ever being cleaned up, or be cleaned up on a node that never registered it. + /// + /// + /// deployed_secrets (ciphertext-only, see SecretArtifact) is the eleventh + /// entry, added for site-secrets artifact deploy. It has an explicit primary key + /// (name) and no FK, so it registers like any other member of this list. + /// mirrored_secrets is deliberately NOT here, for the same reason + /// notification_lists/smtp_configurations are not: which names THIS node has + /// mirrored into its own local secret store is per-node fact, not site-wide state — the + /// two site nodes can be at different points in mirroring the same replicated + /// deployed_secrets row, and replicating the ledger would make one node's progress + /// masquerade as the other's. + /// /// internal static readonly string[] ReplicatedTables = [ @@ -69,6 +82,7 @@ public static class SiteLocalDbSetup "database_connections", "data_connection_definitions", "native_alarm_state", + "deployed_secrets", ]; /// @@ -122,11 +136,12 @@ public static class SiteLocalDbSetup // // The PER-TABLE loop is load-bearing as of LocalDb 0.3.0, not just tidy. The snapshot // flag baselining raises is now per-table, and it is raised by the call that actually - // seeded something — so adding an ELEVENTH table to ReplicatedTables on an already - // replicating site now streams a snapshot of that one table, where through 0.2.x the - // flag was a single per-database bit and the same edit re-streamed all eleven in full, - // in both directions. Nothing about the first boot changes: enabling replication on a - // site that has been running without it seeds all ten at once, the flagged set is + // seeded something — so site-secrets artifact deploy adding deployed_secrets as an + // ELEVENTH entry to ReplicatedTables on an already-replicating site streams a + // snapshot of only that one table, where through 0.2.x the flag was a single + // per-database bit and the same edit would have re-streamed all eleven in full, in + // both directions. Nothing about the first boot changes: enabling replication on a + // site that has been running without it seeds all eleven at once, the flagged set is // therefore every registered table, and the library sends that as an ordinary full // snapshot. A hypothetical "register the whole list in one call" API would silently // give the coarse behaviour back. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs index c7698250..66532b78 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs @@ -3,7 +3,8 @@ using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; /// -/// DDL for the site's nine configuration tables, extracted from +/// DDL for the site's eleven configuration tables (nine original plus +/// deployed_secrets and mirrored_secrets), extracted from /// so it can be applied by whoever owns the database /// file. /// @@ -22,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; public static class SiteStorageSchema { /// - /// Creates the nine site configuration tables when absent, and additively upgrades + /// Creates the eleven site configuration tables when absent, and additively upgrades /// tables created by an older build. Idempotent — safe to run on every startup. /// /// An open connection to the database that should hold the tables. @@ -111,6 +112,30 @@ public static class SiteStorageSchema metadata_json TEXT, PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference) ); + + -- Deployed secrets (ciphertext only — see SecretArtifact). Replicated between the + -- two site nodes by LocalDb CDC (SiteLocalDbSetup.ReplicatedTables). A full-set + -- upsert soft-deletes rows absent from the incoming set via is_removed, never a + -- mass DELETE on a replicated table (that would ship as a destructive resync). + CREATE TABLE IF NOT EXISTS deployed_secrets ( + name TEXT PRIMARY KEY, + row_json TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_utc TEXT NOT NULL, + is_removed INTEGER NOT NULL DEFAULT 0, + deployment_id TEXT NOT NULL, + applied_at TEXT NOT NULL + ); + + -- Node-local ledger of which secret names THIS node has mirrored into its own + -- secret store. Deliberately NOT in SiteLocalDbSetup.ReplicatedTables: mirroring + -- is a per-node action against a per-node secret store, so which names are + -- mirrored (and at what revision) is node-specific, not site-wide state. + CREATE TABLE IF NOT EXISTS mirrored_secrets ( + name TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + mirrored_at TEXT NOT NULL + ); "; command.ExecuteNonQuery(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index 233f73f3..499f7217 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -1,6 +1,8 @@ +using System.Text.Json; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; @@ -658,6 +660,230 @@ public class SiteStorageService _logger.LogDebug("Cleared native alarm state for {Instance}", instanceName); } + // ── Deployed Secrets store (LocalDb-replicated ciphertext) ── + + /// + /// Full-set upsert of the site's deployed secrets: every in + /// is written (is_removed = 0), and every row not named + /// in the set is soft-deleted (is_removed = 1) rather than actually removed. + /// + /// + /// + /// Central ships the complete set of secrets referenced by a site's artifacts on every + /// artifact deploy, mirroring the reconcile contract of + /// for the other artifact tables. Unlike those tables, + /// deployed_secrets is never hard-deleted: it is one of + /// SiteLocalDbSetup.ReplicatedTables, and a mass DELETE on a replicated table + /// would be captured and shipped to the peer as a destructive resync — the same hazard + /// StoreAndForwardStorage.ReplaceAllAsync was deleted for in LocalDb Phase 2. A row + /// that a later deploy re-adds simply flips back to is_removed = 0. + /// + /// + /// row_json is JsonSerializer.Serialize(secret) — the + /// record verbatim, so its byte[] ciphertext fields serialize as base64 and nothing + /// but ciphertext ever lands in this column. + /// + /// + /// The complete set of secrets that should exist on this site. + /// The deployment identifier that applied this set. + /// + /// A task that resolves to the names that were applied (inserted or updated) and the + /// names that were newly marked removed by this call (rows that were previously + /// is_removed = 0; a name already removed by an earlier call is not repeated here). + /// + public async Task<(IReadOnlyList Applied, IReadOnlyList Removed)> UpsertDeployedSecretsAsync( + IReadOnlyList secrets, string deploymentId) + { + ArgumentNullException.ThrowIfNull(secrets); + ArgumentException.ThrowIfNullOrWhiteSpace(deploymentId); + + await using var connection = OpenConnection(); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(); + + var appliedAt = DateTimeOffset.UtcNow.ToString("O"); + var applied = new List(secrets.Count); + + await using (var upsert = connection.CreateCommand()) + { + upsert.Transaction = transaction; + upsert.CommandText = @" + INSERT INTO deployed_secrets (name, row_json, revision, updated_utc, is_removed, deployment_id, applied_at) + VALUES (@name, @json, @revision, @updatedUtc, 0, @deploymentId, @appliedAt) + ON CONFLICT(name) DO UPDATE SET + row_json = excluded.row_json, + revision = excluded.revision, + updated_utc = excluded.updated_utc, + is_removed = 0, + deployment_id = excluded.deployment_id, + applied_at = excluded.applied_at"; + + var pName = upsert.Parameters.Add("@name", SqliteType.Text); + var pJson = upsert.Parameters.Add("@json", SqliteType.Text); + var pRevision = upsert.Parameters.Add("@revision", SqliteType.Integer); + var pUpdatedUtc = upsert.Parameters.Add("@updatedUtc", SqliteType.Text); + var pDeploymentId = upsert.Parameters.Add("@deploymentId", SqliteType.Text); + var pAppliedAt = upsert.Parameters.Add("@appliedAt", SqliteType.Text); + pDeploymentId.Value = deploymentId; + pAppliedAt.Value = appliedAt; + + foreach (var secret in secrets) + { + pName.Value = secret.Name; + pJson.Value = JsonSerializer.Serialize(secret); + pRevision.Value = secret.Revision; + pUpdatedUtc.Value = secret.UpdatedUtc.ToString("O"); + await upsert.ExecuteNonQueryAsync(); + applied.Add(secret.Name); + } + } + + // Names present in the incoming set are never candidates for removal, including + // one that was previously soft-deleted and has just been re-applied above. + var keepParams = secrets.Select((s, i) => (Key: $"@k{i}", Value: s.Name)).ToList(); + var predicate = keepParams.Count == 0 + ? " WHERE is_removed = 0" + : $" WHERE is_removed = 0 AND name NOT IN ({string.Join(", ", keepParams.Select(p => p.Key))})"; + + var removed = new List(); + await using (var select = connection.CreateCommand()) + { + select.Transaction = transaction; + select.CommandText = $"SELECT name FROM deployed_secrets{predicate}"; + foreach (var (key, value) in keepParams) + select.Parameters.AddWithValue(key, value); + + await using var reader = await select.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + removed.Add(reader.GetString(0)); + } + + if (removed.Count > 0) + { + await using var markRemoved = connection.CreateCommand(); + markRemoved.Transaction = transaction; + markRemoved.CommandText = $"UPDATE deployed_secrets SET is_removed = 1{predicate}"; + foreach (var (key, value) in keepParams) + markRemoved.Parameters.AddWithValue(key, value); + + await markRemoved.ExecuteNonQueryAsync(); + } + + await transaction.CommitAsync(); + + _logger.LogInformation( + "Deployed secrets reconcile for deployment {DeploymentId}: applied {AppliedCount}, newly removed {RemovedCount}", + deploymentId, applied.Count, removed.Count); + + return (applied, removed); + } + + /// + /// Returns every row in deployed_secrets, including soft-deleted (is_removed) + /// ones, with each row's round-tripped from row_json. + /// + /// A task that resolves to every stored deployed-secret row. + public async Task> GetDeployedSecretsAsync() + { + await using var connection = OpenConnection(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT name, row_json, is_removed, deployment_id FROM deployed_secrets"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var rowJson = reader.GetString(1); + var secret = JsonSerializer.Deserialize(rowJson) + ?? throw new InvalidOperationException($"deployed_secrets row '{name}' has invalid row_json"); + + results.Add(new DeployedSecretRow( + name, + secret, + reader.GetInt64(2) != 0, + reader.GetString(3))); + } + + return results; + } + + // ── Mirrored Secrets ledger (node-local only — deliberately NOT LocalDb-replicated) ── + + /// + /// Returns every name this node has recorded as mirrored into its own secret store. + /// + /// + /// mirrored_secrets is not in SiteLocalDbSetup.ReplicatedTables on purpose: + /// mirroring writes a name into THIS node's own local secret store, so which names are + /// mirrored — and at what revision — is per-node fact, not site-wide state. Replicating it + /// would tell this node's ledger about the OTHER node's mirroring progress, which is not + /// what it records. + /// + /// A task that resolves to every mirrored-secret ledger row. + public async Task> GetMirroredSecretsAsync() + { + await using var connection = OpenConnection(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT name, revision, mirrored_at FROM mirrored_secrets"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new MirroredSecretRow( + reader.GetString(0), + reader.GetInt64(1), + DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind))); + } + + return results; + } + + /// + /// Records that this node has mirrored at + /// into its own secret store. Uses UPSERT semantics. + /// + /// The mirrored secret's name. + /// The revision that was mirrored. + /// A task that completes when the ledger row has been stored or updated. + public async Task SetMirroredSecretAsync(string name, long revision) + { + await using var connection = OpenConnection(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO mirrored_secrets (name, revision, mirrored_at) + VALUES (@name, @revision, @mirroredAt) + ON CONFLICT(name) DO UPDATE SET + revision = excluded.revision, + mirrored_at = excluded.mirrored_at"; + + command.Parameters.AddWithValue("@name", name); + command.Parameters.AddWithValue("@revision", revision); + command.Parameters.AddWithValue("@mirroredAt", DateTimeOffset.UtcNow.ToString("O")); + + await command.ExecuteNonQueryAsync(); + } + + /// + /// Removes the mirrored-secret ledger row for (e.g. once the + /// secret is no longer deployed to this site and this node has un-mirrored it). + /// + /// The mirrored secret's name. + /// A task that completes when the ledger row has been deleted. + public async Task RemoveMirroredSecretAsync(string name) + { + await using var connection = OpenConnection(); + + await using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM mirrored_secrets WHERE name = @name"; + command.Parameters.AddWithValue("@name", name); + + await command.ExecuteNonQueryAsync(); + } + // ── Shared Script CRUD ── /// @@ -1095,3 +1321,18 @@ public class StoredDataConnectionDefinition /// public record NativeAlarmRow( string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null); + +/// +/// A single row from the site-local, LocalDb-replicated deployed_secrets table. +/// is the round-tripped from +/// row_json — ciphertext only, never plaintext. is true +/// for a soft-deleted row (see ); +/// the row is retained, not hard-deleted, because this table replicates. +/// +public record DeployedSecretRow(string Name, SecretArtifact Secret, bool IsRemoved, string DeploymentId); + +/// +/// A single row from the site-local, node-local (NOT LocalDb-replicated) mirrored_secrets +/// ledger — the names this node has mirrored into its own secret store, and at what revision. +/// +public record MirroredSecretRow(string Name, long Revision, DateTimeOffset MirroredAt); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs index 3e6ad8a5..36fd3007 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs @@ -104,6 +104,21 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal)); } + [Fact] + public void ReplicatedNode_InstallsNoCaptureTriggersOnTheNodeLocalMirroredSecretsLedger() + { + // mirrored_secrets records which names THIS node has mirrored into its own secret + // store — per-node fact, not site-wide state. Capturing it would replicate one node's + // mirroring progress onto the other, so it must stay uncaptured even on a fully + // replicated node. + var db = BuildDatabase(Config(apiKey: "cdc-test-key")); + + var triggers = CaptureTriggers(db); + + Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_mirrored_secrets_", StringComparison.Ordinal)); + Assert.DoesNotContain("mirrored_secrets", db.ReplicatedTables.Keys); + } + [Fact] public void UnreplicatedNode_DropsTriggersLeftByAPreviouslyReplicatedBuild() { @@ -196,13 +211,18 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable // ---- helpers ---------------------------------------------------------------------- - /// The ten replicated tables plus the two deliberately-unregistered ones. + /// + /// The eleven replicated tables, plus the two deliberately-unregistered central-only + /// notification tables, plus the node-local (also deliberately-unregistered) + /// mirrored_secrets ledger. + /// private static readonly string[] AllSiteTables = [ "OperationTracking", "site_events", "sf_messages", "deployed_configurations", "static_attribute_overrides", "shared_scripts", "external_systems", "database_connections", "data_connection_definitions", "native_alarm_state", - "notification_lists", "smtp_configurations", + "deployed_secrets", + "notification_lists", "smtp_configurations", "mirrored_secrets", ]; private static readonly string[] ReplicatedTables = @@ -210,6 +230,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable "OperationTracking", "site_events", "sf_messages", "deployed_configurations", "static_attribute_overrides", "shared_scripts", "external_systems", "database_connections", "data_connection_definitions", "native_alarm_state", + "deployed_secrets", ]; private static void AssertCaptureTriggersCoverTheReplicatedTables(ILocalDb db) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs index 998b93fa..120e30b1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs @@ -15,7 +15,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// RegisterReplicated time, which on a site node is a BOOT failure of the replicating /// pair. This test moves that discovery from the first replicated boot to the build: it walks /// the real site schema (every table SiteLocalDbSetup.OnReady creates) and asserts none -/// of the ten has such an inbound FK. +/// of the eleven (incl. deployed_secrets, +/// added for site-secrets artifact deploy) has such an inbound FK. /// /// /// The schema is built with an UNREPLICATED config on purpose: a replicated one would make diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs index bfb2bb49..74e78ae4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs @@ -245,7 +245,7 @@ public class SiteLocalDbWiringTests : IDisposable var tables = TableNames(db); - // One from each Phase 1 schema, then every Phase 2 table. + // One from each Phase 1 schema, then every Phase 2 table plus the site-secrets tables. Assert.Contains("OperationTracking", tables); Assert.Contains("site_events", tables); Assert.Contains("sf_messages", tables); @@ -254,6 +254,7 @@ public class SiteLocalDbWiringTests : IDisposable "deployed_configurations", "static_attribute_overrides", "shared_scripts", "external_systems", "database_connections", "notification_lists", "data_connection_definitions", "smtp_configurations", "native_alarm_state", + "deployed_secrets", "mirrored_secrets", }) { Assert.Contains(table, tables); @@ -261,19 +262,21 @@ public class SiteLocalDbWiringTests : IDisposable } [Fact] - public void Site_LocalDb_RegistersExactlyTheTenReplicatedTables() + public void Site_LocalDb_RegistersExactlyTheElevenReplicatedTables() { - // The Phase 2 cutover. This is an EXACTLY check rather than a Contains in both - // directions, and both directions are load-bearing. + // The Phase 2 cutover, extended by site-secrets artifact deploy's deployed_secrets. + // This is an EXACTLY check rather than a Contains in both directions, and both + // directions are load-bearing. // // Too few: the table silently stops replicating. Storage still works, every other // test still passes, and the pair just quietly diverges — the failure mode Phase 1 // existed to end. // - // Too many: notification_lists and smtp_configurations must NOT be here. They are - // permanently empty by design, and registering them would open a standing - // replication channel whose only historical payload was plaintext SMTP passwords. - // A Contains-based test would never catch that. + // Too many: notification_lists, smtp_configurations, and mirrored_secrets must NOT + // be here. notification_lists/smtp_configurations are permanently empty by design + // (their only historical payload was a plaintext SMTP password); mirrored_secrets is + // node-local fact (which names THIS node has mirrored), not site-wide state. A + // Contains-based test would never catch either. var db = _host.Services.GetRequiredService(); Assert.Equal( @@ -281,8 +284,9 @@ public class SiteLocalDbWiringTests : IDisposable // Ordinal order: '_' (0x5F) sorts before 'b' (0x62), so // data_connection_definitions precedes database_connections. "OperationTracking", "data_connection_definitions", "database_connections", - "deployed_configurations", "external_systems", "native_alarm_state", - "sf_messages", "shared_scripts", "site_events", "static_attribute_overrides", + "deployed_configurations", "deployed_secrets", "external_systems", + "native_alarm_state", "sf_messages", "shared_scripts", "site_events", + "static_attribute_overrides", ], db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); } @@ -300,6 +304,17 @@ public class SiteLocalDbWiringTests : IDisposable Assert.DoesNotContain("smtp_configurations", db.ReplicatedTables.Keys); } + [Fact] + public void Site_LocalDb_DoesNotReplicateTheNodeLocalMirroredSecretsLedger() + { + // mirrored_secrets exists (SiteStorageSchema creates it) but must never be captured: + // it records which names THIS node has mirrored into its own secret store, which is + // per-node fact, not site-wide state — see SiteLocalDbSetup.ReplicatedTables. + var db = _host.Services.GetRequiredService(); + + Assert.DoesNotContain("mirrored_secrets", db.ReplicatedTables.Keys); + } + [Fact] public void Site_LocalDb_ReplicatedPhase2TablesHaveTheirExpectedPrimaryKeys() { @@ -318,6 +333,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Equal( ["instance_unique_name", "source_canonical_name", "source_reference"], db.ReplicatedTables["native_alarm_state"].PkColumns); + Assert.Equal(["name"], db.ReplicatedTables["deployed_secrets"].PkColumns); } private static HashSet TableNames(ILocalDb db) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs new file mode 100644 index 00000000..79844668 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.TestSupport; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; + +/// +/// Task 8, site-secrets artifact deploy: site-local deployed_secrets +/// (LocalDb-replicated ciphertext) and mirrored_secrets (node-local ledger) stores. +/// +/// +/// Backed by a real temp-file LocalDb: takes an +/// ILocalDb rather than a connection string, and LocalDb has no in-memory mode. +/// +public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable +{ + private readonly TestLocalDb _localDb; + private SiteStorageService _storage = null!; + + public DeployedSecretsStorageTests() + { + _localDb = TestLocalDb.CreateTemp("deployed-secrets"); + } + + public async Task InitializeAsync() + { + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + await _storage.InitializeAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + public void Dispose() + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + // ── deployed_secrets: full-set upsert / soft-delete reconciliation ── + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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)); + } + + [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); + } + + [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 ── + + [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(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(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); + } + + [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 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 ── + + [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); + } + + [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); + } + + [Fact] + public async Task MirroredSecrets_Remove_DeletesTheLedgerRow() + { + await _storage.SetMirroredSecretAsync("Sec1", 1); + + await _storage.RemoveMirroredSecretAsync("Sec1"); + + Assert.Empty(await _storage.GetMirroredSecretsAsync()); + } + + [Fact] + public async Task MirroredSecrets_Remove_OfAnUnknownName_IsANoOp() + { + await _storage.RemoveMirroredSecretAsync("NeverMirrored"); + + Assert.Empty(await _storage.GetMirroredSecretsAsync()); + } + + [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); + } + + 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"); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs index 83b49ddf..4816743c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs @@ -23,13 +23,14 @@ public class SiteStorageSchemaTests SiteStorageSchema.Apply(connection); SiteStorageSchema.Apply(connection); // second run must not throw - // Every table is named explicitly on purpose — a loop asserting "9 tables" + // Every table is named explicitly on purpose — a loop asserting "11 tables" // would still pass if one were renamed. foreach (var table in new[] { "deployed_configurations", "static_attribute_overrides", "shared_scripts", "external_systems", "database_connections", "notification_lists", "data_connection_definitions", "smtp_configurations", "native_alarm_state", + "deployed_secrets", "mirrored_secrets", }) { Assert.True(TableExists(connection, table), $"missing table: {table}");