353af72265
Five review items on the Task 2 server half. 1. GetSecrets now enforces a server-side cap on request.Names, refusing an over-cap batch with ResourceExhausted BEFORE any store read runs. The well-behaved follower chunks at 500, but client discipline is not a server defence — the family precedent (HistorianGateway's max_values) is that the server declines regardless. The new SecretsGrpcHubServerOptions. MaxNamesPerRequest defaults to 1000, comfortably above that chunk so a compliant sweep never meets it, and Validate() refuses a non-positive value at startup rather than letting it read at runtime as a broken follower. The refusal states the cap and the received count — neither is secret, and an operator cannot resize a batch without them — and never a name. The cap sits behind the interceptor, so it is defence in depth against a misbehaving token holder, not an anonymous-DoS control. 2. AddZbSecretsGrpcHub now refuses a second registration. The two calls do not compose: options binding MERGES (a key absent from the second section leaves the first section's value standing) while the SectionPath stamp is last-one-wins, so a hub registered twice could serve a token bound from one section while every failure message named the other. Detected via a private marker registration. The extension also gains real DI coverage — the intended single-call path asserted for token/SectionPath coherence through a built provider, a custom section path stamped end to end, a missing token throwing from the registration call itself, and the duplicate call throwing. 3. Client-streaming and duplex-streaming denial tests, so all four gated handler shapes are pinned rather than only unary and server-streaming, plus a valid-token test that reaches all three streaming continuations. 4. A cancellation test: GetSecrets hands the ServerCallContext token through to the store, pinned against a fake that records it. 5. The proto's service comment stated the ISecretReplicationHub mapping in prose that no longer matched the RPC name; it now says GetManifestAsync -> GetManifest and GetManyAsync -> GetSecrets explicitly. Comment-only — no message or rpc definition is touched. The GetSecrets rpc comment also notes the new RESOURCE_EXHAUSTED cap, which a client author has to know about. Grpc server suite 50 -> 73 tests; full solution suite green, 0 new warnings. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
437 lines
16 KiB
C#
437 lines
16 KiB
C#
using System.Globalization;
|
|
using Google.Protobuf;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
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 async Task GetSecrets_serves_a_request_exactly_at_the_cap()
|
|
{
|
|
var store = new HubFakeSecretStore();
|
|
store.Seed(Row("n1"));
|
|
store.Seed(Row("n2"));
|
|
store.Seed(Row("n3"));
|
|
|
|
var request = new GetSecretsRequest { Names = { "n1", "n2", "n3" } };
|
|
|
|
GetSecretsResponse response = await Service(store, maxNamesPerRequest: 3)
|
|
.GetSecrets(request, HubServerCallContextStub.Empty());
|
|
|
|
Assert.Equal(3, response.Secrets.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSecrets_refuses_a_request_over_the_cap()
|
|
{
|
|
var store = new HubFakeSecretStore();
|
|
store.Seed(Row("n1"));
|
|
|
|
var request = new GetSecretsRequest { Names = { "n1", "n2", "n3", "n4" } };
|
|
|
|
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
|
() => Service(store, maxNamesPerRequest: 3).GetSecrets(request, HubServerCallContextStub.Empty()));
|
|
|
|
Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSecrets_over_the_cap_reads_nothing_at_all()
|
|
{
|
|
// The refusal has to land BEFORE the sequential store loop — a cap that stops the response
|
|
// after doing the reads would defend nothing.
|
|
var store = new HubFakeSecretStore();
|
|
store.Seed(Row("n1"));
|
|
|
|
var request = new GetSecretsRequest { Names = { "n1", "n1", "n1" } };
|
|
|
|
await Assert.ThrowsAsync<RpcException>(
|
|
() => Service(store, maxNamesPerRequest: 2).GetSecrets(request, HubServerCallContextStub.Empty()));
|
|
|
|
Assert.Equal(0, store.GetCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSecrets_cap_refusal_states_the_cap_and_the_count_but_never_a_name()
|
|
{
|
|
var store = new HubFakeSecretStore();
|
|
var request = new GetSecretsRequest { Names = { "db/central", "db/site-a", "db/site-b" } };
|
|
|
|
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
|
() => Service(store, maxNamesPerRequest: 2).GetSecrets(request, HubServerCallContextStub.Empty()));
|
|
|
|
Assert.Contains("2", error.Status.Detail, StringComparison.Ordinal);
|
|
Assert.Contains("3", error.Status.Detail, StringComparison.Ordinal);
|
|
foreach (string name in request.Names)
|
|
{
|
|
Assert.DoesNotContain(name, error.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSecrets_falls_back_to_the_default_cap_when_options_carry_a_nonpositive_one()
|
|
{
|
|
// Registration validates the cap, so this only happens when the options were built some
|
|
// other way — and the safe reading of "0" is the default, never "no cap".
|
|
var store = new HubFakeSecretStore();
|
|
var request = new GetSecretsRequest();
|
|
for (int i = 0; i <= SecretsGrpcHubServerOptions.DefaultMaxNamesPerRequest; i++)
|
|
{
|
|
request.Names.Add($"n{i}");
|
|
}
|
|
|
|
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
|
() => Service(store, maxNamesPerRequest: 0).GetSecrets(request, HubServerCallContextStub.Empty()));
|
|
|
|
Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSecrets_hands_the_call_cancellation_token_to_the_store()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
await cts.CancelAsync();
|
|
|
|
var store = new HubFakeSecretStore();
|
|
store.Seed(Row("present"));
|
|
|
|
await Service(store).GetSecrets(
|
|
new GetSecretsRequest { Names = { "present" } },
|
|
HubServerCallContextStub.WithCancellation(cts.Token));
|
|
|
|
// The fake does not honour the token, so what is pinned here is the propagation itself: a
|
|
// real store sees the caller's cancellation, and an abandoned call stops costing reads.
|
|
Assert.True(store.LastCancellationToken.IsCancellationRequested);
|
|
Assert.Equal(cts.Token, store.LastCancellationToken);
|
|
}
|
|
|
|
[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, int? maxNamesPerRequest = null)
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = "irrelevant-to-the-service" };
|
|
if (maxNamesPerRequest is int max)
|
|
{
|
|
options.MaxNamesPerRequest = max;
|
|
}
|
|
|
|
return new SecretsHubService(store, Options.Create(options), 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>The cancellation token the service passed on the most recent read.</summary>
|
|
public CancellationToken LastCancellationToken { 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++;
|
|
LastCancellationToken = ct;
|
|
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();
|
|
}
|