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