Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubServiceTests.cs
T
Joseph Doherty 7fc77f2790 feat(secrets): Grpc hub server — SecretsHubService + fail-closed bearer interceptor + DI
The server half of the central-hosted pull-only secrets hub. SecretsHubService
adapts the node's own ISecretStore onto secrets_hub.v1 using exactly two read
members (GetManifestAsync, GetAsync) — the proto exposes no write RPC, so a
follower has no path to put a row into central whatever its credentials allow.

GetSecrets omits a name the hub does not hold and skips one that will not parse
as a SecretName; neither is an error, because both describe a follower acting on
a manifest that was true a moment ago. Failing the call would stall the sweep and
let one bad name deny the follower every other row in the batch. Names are
re-validated server-side (the wire type is a plain string) and de-duplicated on
the normalized value.

DomainToWire mirrors the Akka replicator's SecretRowDto discipline exactly:
round-trip "O" timestamps so a DateTimeOffset's offset and ticks survive the
last-writer-wins equality compare, the content type as its enum MEMBER NAME so an
unknown value is rejected rather than decoded to the zero member, and nullable
fields left absent rather than written as "" so null and empty stay distinct.

SecretsHubAuthInterceptor fails closed: with no token configured every call is
denied and one warning names the exact configuration key — never a token value,
presented or configured, at any level. Denial is uniform (one Status, one detail,
no "why") so a caller gets no oracle. Comparison is constant-time over UTF-8
bytes. All four handler shapes are gated, not just unary, so a future RPC cannot
arrive unauthenticated by omission; more than one Authorization header is refused
as a smuggling shape. The type keeps EXACTLY ONE public constructor — Grpc's
activator silently stops invoking an interceptor it cannot construct
unambiguously, and the failure mode is an auth gate that quietly does not run — so
a test pins the count.

Tests: 50, red-first (compile-red, then a fail-closed mutation verified 5 of them
bite). Full Secrets suite 328 passed / 0 failed / 15 live-gated skips, 0 new
warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:09:23 -04:00

323 lines
12 KiB
C#

using System.Globalization;
using Google.Protobuf;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// Covers the read-only hub service and the domain→wire projection behind it. The contract the
/// proto states is binding here: a name the hub does not hold is omitted, never an error, and
/// every <see cref="StoredSecret"/> field crosses losslessly.
/// </summary>
public sealed class SecretsHubServiceTests
{
[Fact]
public async Task GetManifest_returns_every_entry_including_tombstones()
{
var store = new HubFakeSecretStore();
store.Seed(Row("alpha"));
store.Seed(Row("beta", isDeleted: true));
SecretsHubService service = Service(store);
GetManifestResponse response =
await service.GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty());
Assert.Equal(["alpha", "beta"], response.Entries.Select(e => e.Name).Order());
ManifestEntry tombstone = response.Entries.Single(e => e.Name == "beta");
Assert.True(tombstone.IsDeleted);
}
[Fact]
public async Task GetManifest_maps_every_manifest_field()
{
var updated = new DateTimeOffset(2026, 8, 7, 13, 45, 12, 345, TimeSpan.FromHours(-5))
.AddTicks(6789);
var store = new HubFakeSecretStore();
store.SeedManifest(new SecretManifestEntry
{
Name = new SecretName("db/central"),
Revision = 42,
UpdatedUtc = updated,
IsDeleted = true,
});
GetManifestResponse response = await Service(store)
.GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty());
ManifestEntry entry = Assert.Single(response.Entries);
Assert.Equal("db/central", entry.Name);
Assert.Equal(42, entry.Revision);
Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), entry.UpdatedUtc);
Assert.True(entry.IsDeleted);
}
[Fact]
public async Task GetSecrets_omits_names_the_hub_does_not_hold()
{
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
var request = new GetSecretsRequest { Names = { "present", "absent" } };
GetSecretsResponse response =
await Service(store).GetSecrets(request, HubServerCallContextStub.Empty());
SecretRow row = Assert.Single(response.Secrets);
Assert.Equal("present", row.Name);
}
[Fact]
public async Task GetSecrets_returns_tombstoned_rows()
{
// A follower needs the tombstone itself to converge on a delete — omitting it would make a
// deleted secret un-deletable downstream.
var store = new HubFakeSecretStore();
store.Seed(Row("gone", isDeleted: true));
GetSecretsResponse response = await Service(store)
.GetSecrets(new GetSecretsRequest { Names = { "gone" } }, HubServerCallContextStub.Empty());
Assert.True(Assert.Single(response.Secrets).IsDeleted);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("../etc/passwd")]
[InlineData("/rooted")]
[InlineData("has space")]
[InlineData("double//slash")]
public async Task GetSecrets_skips_an_invalid_name_instead_of_faulting(string invalid)
{
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
var request = new GetSecretsRequest { Names = { invalid, "present" } };
GetSecretsResponse response =
await Service(store).GetSecrets(request, HubServerCallContextStub.Empty());
Assert.Equal("present", Assert.Single(response.Secrets).Name);
}
[Fact]
public async Task GetSecrets_normalizes_names_before_looking_them_up()
{
var store = new HubFakeSecretStore();
store.Seed(Row("db/central"));
GetSecretsResponse response = await Service(store).GetSecrets(
new GetSecretsRequest { Names = { " DB/Central " } }, HubServerCallContextStub.Empty());
Assert.Equal("db/central", Assert.Single(response.Secrets).Name);
}
[Fact]
public async Task GetSecrets_returns_one_row_per_distinct_name()
{
var store = new HubFakeSecretStore();
store.Seed(Row("dup"));
GetSecretsResponse response = await Service(store).GetSecrets(
new GetSecretsRequest { Names = { "dup", "dup", "DUP" } }, HubServerCallContextStub.Empty());
Assert.Single(response.Secrets);
Assert.Equal(1, store.GetCallCount);
}
[Fact]
public async Task GetSecrets_on_an_empty_request_reads_nothing()
{
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
GetSecretsResponse response = await Service(store)
.GetSecrets(new GetSecretsRequest(), HubServerCallContextStub.Empty());
Assert.Empty(response.Secrets);
Assert.Equal(0, store.GetCallCount);
}
[Fact]
public void DomainToWire_maps_a_fully_populated_row_field_by_field()
{
// Deliberately non-UTC offsets: DateTimeOffset carries an offset that a UTC-instant
// representation would silently normalize away.
var created = new DateTimeOffset(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-5)).AddTicks(9012);
var updated = new DateTimeOffset(2026, 8, 7, 9, 10, 11, 121, TimeSpan.FromHours(5.5)).AddTicks(3456);
var deleted = new DateTimeOffset(2026, 8, 7, 9, 30, 0, TimeSpan.FromHours(9));
var row = new StoredSecret
{
Name = new SecretName("db/central"),
Description = "central connection string",
ContentType = SecretContentType.ConnectionString,
Ciphertext = [1, 2, 3, 4],
Nonce = [5, 6, 7],
Tag = [8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14],
WrapTag = [15],
KekId = "kek-2026-08",
Revision = 7,
IsDeleted = true,
DeletedUtc = deleted,
CreatedUtc = created,
UpdatedUtc = updated,
CreatedBy = "alice",
UpdatedBy = "bob",
};
SecretRow wire = DomainToWire.ToWire(row);
Assert.Equal("db/central", wire.Name);
Assert.Equal("central connection string", wire.Description);
Assert.Equal("ConnectionString", wire.ContentType);
Assert.Equal(new byte[] { 1, 2, 3, 4 }, wire.Ciphertext.ToByteArray());
Assert.Equal(new byte[] { 5, 6, 7 }, wire.Nonce.ToByteArray());
Assert.Equal(new byte[] { 8, 9 }, wire.Tag.ToByteArray());
Assert.Equal(new byte[] { 10, 11, 12 }, wire.WrappedDek.ToByteArray());
Assert.Equal(new byte[] { 13, 14 }, wire.WrapNonce.ToByteArray());
Assert.Equal(new byte[] { 15 }, wire.WrapTag.ToByteArray());
Assert.Equal("kek-2026-08", wire.KekId);
Assert.Equal(7, wire.Revision);
Assert.True(wire.IsDeleted);
Assert.Equal(deleted.ToString("O", CultureInfo.InvariantCulture), wire.DeletedUtc);
Assert.Equal(created.ToString("O", CultureInfo.InvariantCulture), wire.CreatedUtc);
Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), wire.UpdatedUtc);
Assert.Equal("alice", wire.CreatedBy);
Assert.Equal("bob", wire.UpdatedBy);
}
[Fact]
public void DomainToWire_leaves_nullable_fields_absent_rather_than_empty()
{
StoredSecret row = Row("minimal");
SecretRow wire = DomainToWire.ToWire(row);
Assert.False(wire.HasDescription);
Assert.False(wire.HasDeletedUtc);
Assert.False(wire.HasCreatedBy);
Assert.False(wire.HasUpdatedBy);
}
[Fact]
public void DomainToWire_distinguishes_an_empty_string_from_a_null()
{
StoredSecret row = Row("empties") with { Description = string.Empty, CreatedBy = string.Empty };
SecretRow wire = DomainToWire.ToWire(row);
Assert.True(wire.HasDescription);
Assert.Equal(string.Empty, wire.Description);
Assert.True(wire.HasCreatedBy);
Assert.Equal(string.Empty, wire.CreatedBy);
}
[Theory]
[InlineData(SecretContentType.Text, "Text")]
[InlineData(SecretContentType.ConnectionString, "ConnectionString")]
[InlineData(SecretContentType.Json, "Json")]
[InlineData(SecretContentType.BinaryBase64, "BinaryBase64")]
public void DomainToWire_writes_the_content_type_member_name(SecretContentType type, string expected)
{
SecretRow wire = DomainToWire.ToWire(Row("typed") with { ContentType = type });
Assert.Equal(expected, wire.ContentType);
}
private static SecretsHubService Service(ISecretStore store) =>
new(store, NullLogger<SecretsHubService>.Instance);
private static StoredSecret Row(string name, bool isDeleted = false) => new()
{
Name = new SecretName(name),
ContentType = SecretContentType.Text,
Ciphertext = [1],
Nonce = [2],
Tag = [3],
WrappedDek = [4],
WrapNonce = [5],
WrapTag = [6],
KekId = "kek",
Revision = 1,
IsDeleted = isDeleted,
DeletedUtc = isDeleted ? DateTimeOffset.UnixEpoch : null,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch,
};
}
/// <summary>
/// An in-memory <see cref="ISecretStore"/>. Only the two read members the hub adapts are
/// implemented; the write half throws, which is itself part of the pull-only pin — a hub that ever
/// started writing would fail loudly in these tests.
/// </summary>
internal sealed class HubFakeSecretStore : ISecretStore
{
private readonly Dictionary<string, StoredSecret> _rows = new(StringComparer.Ordinal);
private readonly List<SecretManifestEntry> _manifestOverride = [];
/// <summary>How many single-row reads the service issued.</summary>
public int GetCallCount { get; private set; }
/// <summary>Adds a row, and the manifest entry derived from it.</summary>
public void Seed(StoredSecret row) => _rows[row.Name.Value] = row;
/// <summary>Adds a manifest entry with no backing row, for manifest-shape assertions.</summary>
public void SeedManifest(SecretManifestEntry entry) => _manifestOverride.Add(entry);
/// <inheritdoc/>
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
{
GetCallCount++;
return Task.FromResult(_rows.GetValueOrDefault(name.Value));
}
/// <inheritdoc/>
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
{
if (_manifestOverride.Count > 0)
{
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>(_manifestOverride);
}
IReadOnlyList<SecretManifestEntry> entries = _rows.Values
.Select(r => new SecretManifestEntry
{
Name = r.Name,
Revision = r.Revision,
UpdatedUtc = r.UpdatedUtc,
IsDeleted = r.IsDeleted,
})
.ToList();
return Task.FromResult(entries);
}
/// <inheritdoc/>
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc/>
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc/>
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc/>
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc/>
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
throw new NotSupportedException();
}