test(secrets): Grpc hub end-to-end integration suite + pull-only contract pin
Adds the end-to-end layer the unit suites structurally cannot reach: a real
in-process ASP.NET Core host serving SecretsHubService through
AddZbSecretsGrpcHub/MapZbSecretsHub, a follower built by the shipped
AddZbSecretsGrpcHubClient, a real gRPC call between them, and real SQLite
stores on both sides. The class of defect this catches is pieces that are each
individually correct and are never connected to one another — for instance the
interceptor's warn-once, which only holds if the container really hands gRPC
the singleton it registered.
Covered: byte-identical convergence over the wire (every ciphertext/nonce/tag/
wrapped-DEK byte, kek_id, revision, both timestamps INCLUDING their offsets,
across a tombstone, a nulls-everywhere row and an empty-string-description row);
delete propagation; a hub unreachable at follower boot leaving the last-known-
good store intact and converging once it answers; the auth negatives fail-closed
across the wire with one uniform denial detail; the per-request name cap as
ResourceExhausted; and an RpcException from the call invoker escaping the hub
client unchanged, so a transport fault stays distinguishable from a parse fault.
The credential-hygiene assertions are made against logs captured at Trace on
BOTH hosts, so "no token material ever reaches a sink" is asserted at the most
verbose setting a deployment could switch on.
SecretsHubContractPinTests reads the generated descriptor — not the .proto text
— and pins the RPC set to exactly {GetManifest, GetSecrets}, plus unary-only and
the package-qualified service name. The hub is pull-only by construction:
updates originate at central only, and the absence of a write RPC is what
enforces that, not authorization a mis-wired follower may hold more of than
intended. A third RPC silently retires the guarantee, so the failure message
says so.
Microsoft.AspNetCore.TestHost pinned at 10.0.7 — the version ZB.MOM.WW.LocalDb,
OtOpcUa and ScadaBridge already pin, matching the Microsoft.Extensions.* line.
Full suite: 436 passed / 0 failed / 15 skipped, no new warnings.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -77,6 +77,11 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
|
||||
<!-- In-process ASP.NET Core host for the gRPC secrets-hub end-to-end suite. 10.0.7 is the
|
||||
version the rest of the family already pins (ZB.MOM.WW.LocalDb, OtOpcUa, ScadaBridge) and
|
||||
matches the Microsoft.Extensions.* line above, so the test host cannot drag a second
|
||||
ASP.NET Core version into the graph. -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.7" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="bunit" Version="1.40.0" />
|
||||
</ItemGroup>
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using Google.Protobuf.Reflection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the <c>secrets_hub.v1</c> service surface to its two read RPCs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The narrow contract IS the enforcement.</b> In this topology every update originates at
|
||||
/// central and a site node is a strict follower, so <c>ISecretReplicationHub</c>'s third method —
|
||||
/// <c>ApplyReplicatedAsync</c> — is deliberately not mirrored onto the wire. With no write RPC
|
||||
/// there, a mis-wired or compromised site node has no way to push a row into central's store
|
||||
/// regardless of what its authorization happens to allow. That guarantee costs nothing to keep and
|
||||
/// is silently lost the moment a third RPC appears, which is why it is pinned here rather than left
|
||||
/// to the proto's comment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read off the generated descriptor rather than the .proto text: the descriptor is what the server
|
||||
/// actually binds and the client actually dials, so a method that reached the wire some other way
|
||||
/// would still be caught.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecretsHubContractPinTests
|
||||
{
|
||||
private const string PullOnlyRationale =
|
||||
"The secrets hub is PULL-ONLY by construction: updates originate at central only, and the " +
|
||||
"absence of a write RPC is what enforces it — not authorization, which a mis-wired or " +
|
||||
"compromised follower may hold more of than intended. Adding an RPC to this service " +
|
||||
"retires that guarantee. If a write path is genuinely wanted, it needs its own design " +
|
||||
"decision and its own review, not a passing test.";
|
||||
|
||||
[Fact]
|
||||
public void The_hub_service_exposes_exactly_the_two_read_rpcs()
|
||||
{
|
||||
string[] rpcs = RpcNames();
|
||||
|
||||
Assert.True(
|
||||
rpcs is ["GetManifest", "GetSecrets"],
|
||||
$"secrets_hub.v1 SecretsHub must expose exactly [GetManifest, GetSecrets]; it exposes " +
|
||||
$"[{string.Join(", ", rpcs)}]. {PullOnlyRationale}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_rpc_on_the_hub_service_streams_in_either_direction()
|
||||
{
|
||||
// A streaming RPC is a channel a follower can write into for as long as it stays open. The
|
||||
// contract is unary today; that is a property worth pinning rather than rediscovering.
|
||||
foreach (MethodDescriptor method in SecretsHub.Descriptor.Methods)
|
||||
{
|
||||
Assert.False(
|
||||
method.IsClientStreaming || method.IsServerStreaming,
|
||||
$"secrets_hub.v1 SecretsHub.{method.Name} is a streaming RPC. {PullOnlyRationale}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_service_keeps_its_package_qualified_identity()
|
||||
{
|
||||
// The full name is the wire address every deployed follower dials. Renaming the service or
|
||||
// its package is a silent break for anything already running, so it is pinned alongside the
|
||||
// method set.
|
||||
Assert.Equal("zb.mom.ww.secrets.hub.v1.SecretsHub", SecretsHub.Descriptor.FullName);
|
||||
}
|
||||
|
||||
private static string[] RpcNames() =>
|
||||
[.. SecretsHub.Descriptor.Methods.Select(method => method.Name).Order(StringComparer.Ordinal)];
|
||||
}
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The gRPC secrets hub end to end: a central node hosting the real service through its own DI and
|
||||
/// interceptor, a site node running the real client and sweep through its own DI, and a real gRPC
|
||||
/// call between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything the unit suites necessarily substitute is real here — the registration extensions, the
|
||||
/// generated client and service, the fail-closed interceptor as the container actually activates it,
|
||||
/// protobuf encoding on both sides, and two real SQLite stores. What that buys is the class of defect
|
||||
/// no unit test can reach: pieces that are each correct and are never connected to one another.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No credential in this file is a real one.</b> The two token values are fabricated for these
|
||||
/// tests, and one of the tests exists to assert that neither ever reaches a log sink on either side.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecretsHubIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConfiguredToken = "central-hub-credential-7f3a";
|
||||
private const string WrongToken = "impostor-credential-91cd";
|
||||
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempSecretStore _hubStore = new("hub");
|
||||
private readonly TempSecretStore _localStore = new("site");
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
{
|
||||
await disposable.DisposeAsync();
|
||||
}
|
||||
|
||||
_localStore.Dispose();
|
||||
_hubStore.Dispose();
|
||||
}
|
||||
|
||||
// ---- 1. end-to-end convergence ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task One_sweep_carries_every_row_across_the_wire_byte_identically()
|
||||
{
|
||||
// The four rows between them cover every shape the wire has to keep distinguishable: a fully
|
||||
// populated row with non-UTC offsets on both timestamps, a row whose every nullable field is
|
||||
// null, a row whose nullable strings are deliberately EMPTY rather than absent, and a
|
||||
// tombstone. Empty-versus-null is the one a plain proto3 string would silently collapse.
|
||||
StoredSecret populated = Row("db/central", 0xA1) with
|
||||
{
|
||||
Description = "central connection string",
|
||||
ContentType = SecretContentType.ConnectionString,
|
||||
Revision = 7,
|
||||
CreatedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-5)).AddTicks(9012),
|
||||
UpdatedUtc = new DateTimeOffset(2026, 8, 7, 9, 10, 11, 121, TimeSpan.FromHours(5.5)).AddTicks(3456),
|
||||
CreatedBy = "alice",
|
||||
UpdatedBy = "bob",
|
||||
};
|
||||
StoredSecret nulls = Row("api/token", 0xB2);
|
||||
StoredSecret empties = Row("svc/empties", 0xC3) with
|
||||
{
|
||||
Description = string.Empty,
|
||||
CreatedBy = string.Empty,
|
||||
UpdatedBy = string.Empty,
|
||||
};
|
||||
StoredSecret tombstone = Row("retired/legacy", 0xD4) with
|
||||
{
|
||||
IsDeleted = true,
|
||||
DeletedUtc = new DateTimeOffset(2026, 8, 7, 9, 30, 0, TimeSpan.FromHours(9)),
|
||||
Revision = 3,
|
||||
UpdatedBy = "carol",
|
||||
};
|
||||
|
||||
await SeedHubAsync(populated, nulls, empties, tombstone);
|
||||
|
||||
SecretsHubTestClient client = await ConnectAsync();
|
||||
|
||||
int pulled = await client.Sweeper.SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(4, pulled);
|
||||
|
||||
foreach (StoredSecret expected in new[] { populated, nulls, empties, tombstone })
|
||||
{
|
||||
StoredSecret hubRow = await RequireAsync(_hubStore.Store, expected.Name);
|
||||
StoredSecret localRow = await RequireAsync(_localStore.Store, expected.Name);
|
||||
|
||||
AssertIdentical(hubRow, localRow);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Convergence_evicts_the_resolver_cache_for_exactly_the_rows_it_pulled()
|
||||
{
|
||||
await SeedHubAsync(Row("db/central", 0xA1), Row("api/token", 0xB2));
|
||||
await _localStore.Store.ApplyReplicatedAsync(Row("only/local", 0x11), CancellationToken.None);
|
||||
|
||||
SecretsHubTestClient client = await ConnectAsync();
|
||||
|
||||
await client.Sweeper.SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
["api/token", "db/central"],
|
||||
client.Invalidated.Select(name => name.Value).Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_converged_follower_pulls_nothing_on_the_next_sweep()
|
||||
{
|
||||
await SeedHubAsync(Row("db/central", 0xA1));
|
||||
|
||||
SecretsHubTestClient client = await ConnectAsync();
|
||||
await client.Sweeper.SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, await client.Sweeper.SweepAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- 2. delete propagation --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_hub_side_tombstone_replaces_a_live_local_row()
|
||||
{
|
||||
StoredSecret live = Row("api/token", 0xB2) with
|
||||
{
|
||||
Revision = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
UpdatedUtc = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
};
|
||||
|
||||
await SeedHubAsync(live);
|
||||
await _localStore.Store.ApplyReplicatedAsync(live, CancellationToken.None);
|
||||
|
||||
SecretsHubTestClient client = await ConnectAsync();
|
||||
|
||||
// Nothing to do yet: both sides hold the identical live row.
|
||||
Assert.Equal(0, await client.Sweeper.SweepAsync(CancellationToken.None));
|
||||
|
||||
// A real delete on the hub — higher revision, newer timestamp, tombstone flag set.
|
||||
Assert.True(await _hubStore.Store.DeleteAsync(live.Name, "admin", CancellationToken.None));
|
||||
|
||||
Assert.Equal(1, await client.Sweeper.SweepAsync(CancellationToken.None));
|
||||
|
||||
StoredSecret hubRow = await RequireAsync(_hubStore.Store, live.Name);
|
||||
StoredSecret localRow = await RequireAsync(_localStore.Store, live.Name);
|
||||
|
||||
Assert.True(localRow.IsDeleted);
|
||||
Assert.NotNull(localRow.DeletedUtc);
|
||||
AssertIdentical(hubRow, localRow);
|
||||
}
|
||||
|
||||
// ---- 3. hub outage at boot, then recovery -----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_hub_that_is_down_at_boot_leaves_the_last_known_good_store_intact_and_converges_on_recovery()
|
||||
{
|
||||
StoredSecret lastKnownGood = Row("db/central", 0x77) with
|
||||
{
|
||||
Revision = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
UpdatedUtc = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
};
|
||||
await _localStore.Store.ApplyReplicatedAsync(lastKnownGood, CancellationToken.None);
|
||||
|
||||
StoredSecret newer = lastKnownGood with
|
||||
{
|
||||
Ciphertext = [0x88],
|
||||
Revision = 2,
|
||||
UpdatedUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
await SeedHubAsync(newer);
|
||||
|
||||
// The hub exists, but this node cannot reach it: the transport is disconnected before the
|
||||
// node boots, which is the ordinary "central is down while a site restarts" case.
|
||||
var transport = new SwitchableHttpHandler();
|
||||
SecretsHubTestServer server = await StartHubAsync();
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, transport, ConfiguredToken));
|
||||
|
||||
await client.Sweeper.StartAsync(CancellationToken.None);
|
||||
|
||||
await WaitForAsync(
|
||||
() => client.Logs.WarningsFrom<GrpcSecretSyncService>().Count > 0,
|
||||
"the startup sweep never reported the outage");
|
||||
|
||||
// The node did not crash, and it is still serving exactly what it had.
|
||||
Assert.NotNull(client.Sweeper.ExecuteTask);
|
||||
Assert.False(client.Sweeper.ExecuteTask!.IsFaulted);
|
||||
AssertIdentical(lastKnownGood, await RequireAsync(_localStore.Store, lastKnownGood.Name));
|
||||
|
||||
// Central comes back.
|
||||
transport.ConnectTo(server.CreateHandler());
|
||||
|
||||
Assert.Equal(1, await client.Sweeper.SweepAsync(CancellationToken.None));
|
||||
AssertIdentical(
|
||||
await RequireAsync(_hubStore.Store, newer.Name),
|
||||
await RequireAsync(_localStore.Store, newer.Name));
|
||||
|
||||
await client.Sweeper.StopAsync(CancellationToken.None);
|
||||
Assert.False(client.Sweeper.ExecuteTask!.IsFaulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_outage_costs_exactly_one_warning_and_no_row_ever_disappears()
|
||||
{
|
||||
// The failure mode this guards is not "the sweep failed" — it is a hub that answers "I have
|
||||
// nothing" being indistinguishable from a hub that does not answer, which would empty the
|
||||
// follower's store. A sweep against a dead transport must move nothing at all.
|
||||
StoredSecret held = Row("api/token", 0x55);
|
||||
await _localStore.Store.ApplyReplicatedAsync(held, CancellationToken.None);
|
||||
|
||||
var transport = new SwitchableHttpHandler();
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, transport, ConfiguredToken));
|
||||
|
||||
await client.Sweeper.StartAsync(CancellationToken.None);
|
||||
await WaitForAsync(
|
||||
() => client.Logs.WarningsFrom<GrpcSecretSyncService>().Count > 0,
|
||||
"the startup sweep never reported the outage");
|
||||
await client.Sweeper.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(client.Logs.WarningsFrom<GrpcSecretSyncService>());
|
||||
AssertIdentical(held, await RequireAsync(_localStore.Store, held.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_transport_failure_reaches_the_raw_sweep_as_a_fault_rather_than_an_empty_answer()
|
||||
{
|
||||
// The containment above is only meaningful because the underlying call really does fail.
|
||||
var transport = new SwitchableHttpHandler();
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(
|
||||
_localStore.Store, transport, ConfiguredToken, syncOnStartup: false));
|
||||
|
||||
await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.Sweeper.SweepAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- 4. authentication, fail-closed, over the wire ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_wrong_bearer_token_is_refused()
|
||||
{
|
||||
await SeedHubAsync(Row("db/central", 0xA1));
|
||||
SecretsHubTestServer server = await StartHubAsync();
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), WrongToken));
|
||||
|
||||
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.Hub.GetManifestAsync(CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
|
||||
Assert.Null(await _localStore.Store.GetAsync(new SecretName("db/central"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_call_with_no_credential_at_all_is_refused_in_exactly_the_same_words()
|
||||
{
|
||||
// Uniform denial, asserted where it matters: across the wire, between two DIFFERENT failure
|
||||
// modes. A caller that could tell "no token" from "wrong token" would have a free oracle.
|
||||
SecretsHubTestServer server = await StartHubAsync();
|
||||
using GrpcChannel channel = ChannelOver(server.CreateHandler());
|
||||
var bare = new SecretsHub.SecretsHubClient(channel);
|
||||
|
||||
RpcException noHeader = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await bare.GetManifestAsync(new GetManifestRequest()));
|
||||
|
||||
SecretsHubTestClient wrong = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), WrongToken));
|
||||
RpcException wrongToken = await Assert.ThrowsAsync<RpcException>(
|
||||
() => wrong.Hub.GetManifestAsync(CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCode.Unauthenticated, noHeader.StatusCode);
|
||||
Assert.Equal(wrongToken.StatusCode, noHeader.StatusCode);
|
||||
Assert.Equal(wrongToken.Status.Detail, noHeader.Status.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hub_with_no_token_configured_refuses_even_a_well_formed_credential()
|
||||
{
|
||||
SecretsHubTestServer server = Track(
|
||||
await SecretsHubTestServer.StartWithNoTokenConfiguredAsync(_hubStore.Store));
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), ConfiguredToken));
|
||||
|
||||
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.Hub.GetManifestAsync(CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unconfigured_hub_warns_once_however_many_callers_it_refuses()
|
||||
{
|
||||
// Once per process, not once per call — and the interceptor can only honour that if the
|
||||
// container hands gRPC the singleton it registered. That wiring is exactly what a unit test
|
||||
// constructing the interceptor by hand cannot check.
|
||||
SecretsHubTestServer server = Track(
|
||||
await SecretsHubTestServer.StartWithNoTokenConfiguredAsync(_hubStore.Store));
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), ConfiguredToken));
|
||||
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.Hub.GetManifestAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
CapturedLog warning = Assert.Single(server.Logs.WarningsFrom<SecretsHubAuthInterceptor>());
|
||||
Assert.Contains("Secrets:GrpcHub:BearerToken", warning.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_log_line_on_either_side_carries_credential_material()
|
||||
{
|
||||
await SeedHubAsync(Row("db/central", 0xA1));
|
||||
SecretsHubTestServer server = await StartHubAsync();
|
||||
|
||||
SecretsHubTestClient accepted = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), ConfiguredToken));
|
||||
await accepted.Sweeper.SweepAsync(CancellationToken.None);
|
||||
|
||||
SecretsHubTestClient refused = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), WrongToken));
|
||||
await Assert.ThrowsAsync<RpcException>(
|
||||
() => refused.Hub.GetManifestAsync(CancellationToken.None));
|
||||
|
||||
IEnumerable<CapturedLog> everything = server.Logs.Entries
|
||||
.Concat(accepted.Logs.Entries)
|
||||
.Concat(refused.Logs.Entries);
|
||||
|
||||
// Captured at Trace on both hosts, so this covers the most verbose setting a deployment
|
||||
// could switch on — the configured token, the presented one, and the header that carried it.
|
||||
Assert.NotEmpty(everything);
|
||||
foreach (CapturedLog entry in everything)
|
||||
{
|
||||
Assert.DoesNotContain(ConfiguredToken, entry.AllText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(WrongToken, entry.AllText, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 5. the request cap, over the wire ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_fetch_over_the_configured_cap_is_refused_as_resource_exhausted()
|
||||
{
|
||||
await SeedHubAsync(Row("db/central", 0xA1), Row("api/token", 0xB2), Row("svc/empties", 0xC3));
|
||||
SecretsHubTestServer server = await StartHubAsync(maxNamesPerRequest: 2);
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), ConfiguredToken));
|
||||
|
||||
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.Hub.GetManyAsync(
|
||||
[new SecretName("db/central"), new SecretName("api/token"), new SecretName("svc/empties")],
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fetch_exactly_at_the_cap_is_served()
|
||||
{
|
||||
await SeedHubAsync(Row("db/central", 0xA1), Row("api/token", 0xB2));
|
||||
SecretsHubTestServer server = await StartHubAsync(maxNamesPerRequest: 2);
|
||||
SecretsHubTestClient client = Track(
|
||||
SecretsHubTestClient.Create(_localStore.Store, server.CreateHandler(), ConfiguredToken));
|
||||
|
||||
IReadOnlyList<StoredSecret> rows = await client.Hub.GetManyAsync(
|
||||
[new SecretName("db/central"), new SecretName("api/token")], CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
}
|
||||
|
||||
// ---- 6. transport faults stay distinguishable from parse faults --------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task An_RpcException_from_the_call_invoker_escapes_the_hub_client_unchanged()
|
||||
{
|
||||
// The client catches PARSE failures per item and drops them; it must never catch a transport
|
||||
// failure. If it did, an unreachable hub would be indistinguishable from an empty one, and
|
||||
// the sweep would have no way to tell "central said nothing" from "central has nothing".
|
||||
var thrown = new RpcException(new Status(StatusCode.Unavailable, "hub unreachable"));
|
||||
var invoker = new FakeSecretsHubCallInvoker { FaultWith = () => thrown };
|
||||
GrpcSecretsHubClient client = ClientOver(invoker);
|
||||
|
||||
RpcException fromManifest = await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.GetManifestAsync(CancellationToken.None));
|
||||
RpcException fromFetch = await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.GetManyAsync([new SecretName("db/central")], CancellationToken.None));
|
||||
|
||||
Assert.Same(thrown, fromManifest);
|
||||
Assert.Same(thrown, fromFetch);
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------------------------
|
||||
|
||||
private static GrpcSecretsHubClient ClientOver(CallInvoker invoker) => new(
|
||||
new SecretsHub.SecretsHubClient(invoker),
|
||||
Options.Create(new SecretsGrpcHubClientOptions
|
||||
{
|
||||
Endpoint = "http://central.test",
|
||||
BearerToken = "unused-by-the-invoker",
|
||||
}),
|
||||
new RecordingLogger<GrpcSecretsHubClient>());
|
||||
|
||||
private static GrpcChannel ChannelOver(HttpMessageHandler handler) =>
|
||||
GrpcChannel.ForAddress("http://central.test", new GrpcChannelOptions { HttpHandler = handler });
|
||||
|
||||
private async Task<SecretsHubTestServer> StartHubAsync(int? maxNamesPerRequest = null) =>
|
||||
Track(await SecretsHubTestServer.StartAsync(
|
||||
_hubStore.Store, ConfiguredToken, maxNamesPerRequest));
|
||||
|
||||
private async Task<SecretsHubTestClient> ConnectAsync()
|
||||
{
|
||||
SecretsHubTestServer server = await StartHubAsync();
|
||||
|
||||
return Track(SecretsHubTestClient.Create(
|
||||
_localStore.Store, server.CreateHandler(), ConfiguredToken, syncOnStartup: false));
|
||||
}
|
||||
|
||||
private T Track<T>(T disposable) where T : IAsyncDisposable
|
||||
{
|
||||
_disposables.Add(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
// Seeded through ApplyReplicatedAsync rather than UpsertAsync: the upsert path stamps its own
|
||||
// timestamps and revision, which would erase the exact values these tests are about.
|
||||
private async Task SeedHubAsync(params StoredSecret[] rows)
|
||||
{
|
||||
foreach (StoredSecret row in rows)
|
||||
{
|
||||
await _hubStore.Store.ApplyReplicatedAsync(row, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StoredSecret> RequireAsync(ISecretStore store, SecretName name)
|
||||
{
|
||||
StoredSecret? row = await store.GetAsync(name, CancellationToken.None);
|
||||
|
||||
Assert.True(row is not null, $"expected the store to hold '{name.Value}'");
|
||||
return row!;
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
|
||||
while (!condition() && elapsed.Elapsed < Patience)
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
Assert.True(condition(), because);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Field-by-field equality of two rows — every byte array, every timestamp, every nullable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Written out rather than delegated to record equality on purpose: <see cref="StoredSecret"/>
|
||||
/// holds <c>byte[]</c>, which records compare by REFERENCE, so <c>Assert.Equal(a, b)</c> on the
|
||||
/// whole row would pass only for the same instance and silently prove nothing about a row that
|
||||
/// crossed a wire. The byte-identical claim in the live gate is exactly this assertion.
|
||||
/// </remarks>
|
||||
private static void AssertIdentical(StoredSecret expected, StoredSecret actual)
|
||||
{
|
||||
Assert.Equal(expected.Name.Value, actual.Name.Value);
|
||||
Assert.Equal(expected.Description, actual.Description);
|
||||
Assert.Equal(expected.ContentType, actual.ContentType);
|
||||
Assert.Equal(expected.Ciphertext, actual.Ciphertext);
|
||||
Assert.Equal(expected.Nonce, actual.Nonce);
|
||||
Assert.Equal(expected.Tag, actual.Tag);
|
||||
Assert.Equal(expected.WrappedDek, actual.WrappedDek);
|
||||
Assert.Equal(expected.WrapNonce, actual.WrapNonce);
|
||||
Assert.Equal(expected.WrapTag, actual.WrapTag);
|
||||
Assert.Equal(expected.KekId, actual.KekId);
|
||||
Assert.Equal(expected.Revision, actual.Revision);
|
||||
Assert.Equal(expected.IsDeleted, actual.IsDeleted);
|
||||
Assert.Equal(expected.DeletedUtc, actual.DeletedUtc);
|
||||
Assert.Equal(expected.CreatedUtc, actual.CreatedUtc);
|
||||
Assert.Equal(expected.UpdatedUtc, actual.UpdatedUtc);
|
||||
Assert.Equal(expected.CreatedBy, actual.CreatedBy);
|
||||
Assert.Equal(expected.UpdatedBy, actual.UpdatedBy);
|
||||
|
||||
// Offset, not just instant: DateTimeOffset equality is instant-only, so a wire form that
|
||||
// normalized every timestamp to UTC would satisfy the assertions above while quietly
|
||||
// discarding the offset the store persisted.
|
||||
Assert.Equal(expected.CreatedUtc.Offset, actual.CreatedUtc.Offset);
|
||||
Assert.Equal(expected.UpdatedUtc.Offset, actual.UpdatedUtc.Offset);
|
||||
Assert.Equal(expected.DeletedUtc?.Offset, actual.DeletedUtc?.Offset);
|
||||
}
|
||||
|
||||
private static StoredSecret Row(string name, byte marker) => new()
|
||||
{
|
||||
Name = new SecretName(name),
|
||||
ContentType = SecretContentType.Text,
|
||||
Ciphertext = [marker],
|
||||
Nonce = [1, 2],
|
||||
Tag = [3, 4, 5],
|
||||
WrappedDek = [6, 7, 8, 9],
|
||||
WrapNonce = [10],
|
||||
WrapTag = [11, 12],
|
||||
KekId = "sha256:kek-under-test",
|
||||
Revision = 0,
|
||||
CreatedUtc = new DateTimeOffset(2026, 8, 7, 1, 2, 3, TimeSpan.Zero),
|
||||
UpdatedUtc = new DateTimeOffset(2026, 8, 7, 1, 2, 3, TimeSpan.Zero),
|
||||
};
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
||||
|
||||
/// <summary>One captured log entry: enough to assert what was said and to prove what was not.</summary>
|
||||
/// <param name="Category">The logger category the entry was written to.</param>
|
||||
/// <param name="Level">The level it was written at.</param>
|
||||
/// <param name="Message">The formatted message.</param>
|
||||
/// <param name="Exception">The exception's full text, or the empty string when there was none.</param>
|
||||
internal sealed record CapturedLog(string Category, LogLevel Level, string Message, string Exception)
|
||||
{
|
||||
/// <summary>Message and exception text together — everything this entry could leak.</summary>
|
||||
public string AllText => Message + "\n" + Exception;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="ILoggerProvider"/> that records every entry written anywhere in a host, so a test
|
||||
/// can assert both what was logged and — the security half — what never appeared in any log line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered at <see cref="LogLevel.Trace"/> deliberately: an assertion that no bearer token ever
|
||||
/// reaches a sink is only worth making against the most verbose setting a deployment could enable.
|
||||
/// </remarks>
|
||||
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly ConcurrentQueue<CapturedLog> _entries = new();
|
||||
|
||||
/// <summary>Everything logged so far, in order.</summary>
|
||||
public IReadOnlyList<CapturedLog> Entries => [.. _entries];
|
||||
|
||||
/// <summary>Entries at <see cref="LogLevel.Warning"/> written by <typeparamref name="T"/>.</summary>
|
||||
/// <typeparam name="T">The logging type whose category is matched.</typeparam>
|
||||
/// <returns>The matching warnings, in order.</returns>
|
||||
public IReadOnlyList<CapturedLog> WarningsFrom<T>() =>
|
||||
[.. _entries.Where(entry =>
|
||||
entry.Level == LogLevel.Warning &&
|
||||
string.Equals(entry.Category, typeof(T).FullName, StringComparison.Ordinal))];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, _entries);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to release, and the entries deliberately outlive the host: assertions run after
|
||||
// the host that produced them has been torn down.
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger(string category, ConcurrentQueue<CapturedLog> sink) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formatter);
|
||||
|
||||
sink.Enqueue(new CapturedLog(
|
||||
category,
|
||||
logLevel,
|
||||
formatter(state, exception),
|
||||
exception?.ToString() ?? string.Empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="HttpMessageHandler"/> whose backing transport can be swapped at runtime, including
|
||||
/// to "nothing at all".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is how the hub-outage scenario is expressed without a real socket: with no inner handler the
|
||||
/// send fails the way a refused connection does, and installing one afterwards models central coming
|
||||
/// back. The channel, the generated client and the sweep are all the real ones and never learn that
|
||||
/// anything changed — which is the point, since the behaviour under test is that the follower keeps
|
||||
/// serving its last-known-good store and converges once the hub answers again.
|
||||
/// </remarks>
|
||||
internal sealed class SwitchableHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private volatile HttpMessageInvoker? _inner;
|
||||
|
||||
/// <summary>Points the handler at a live transport.</summary>
|
||||
/// <param name="handler">The handler to forward to.</param>
|
||||
public void ConnectTo(HttpMessageHandler handler) => _inner = new HttpMessageInvoker(handler);
|
||||
|
||||
/// <summary>Takes the transport away, so every send fails as an unreachable host does.</summary>
|
||||
public void Disconnect() => _inner = null;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
HttpMessageInvoker? inner = _inner;
|
||||
|
||||
return inner is null
|
||||
? throw new HttpRequestException("The secrets hub refused the connection.")
|
||||
: inner.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A real SQLite-backed <see cref="ISecretStore"/> in a temp file, deleted on dispose.</summary>
|
||||
/// <remarks>
|
||||
/// Real store rather than a dictionary, for the same reason the offline sweep suite uses one: the
|
||||
/// behaviour under test is last-writer-wins convergence, and a hand-rolled fake would re-implement
|
||||
/// the very logic the assertions exist to check.
|
||||
/// </remarks>
|
||||
internal sealed class TempSecretStore : IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
|
||||
/// <summary>Creates and migrates a fresh store.</summary>
|
||||
/// <param name="label">A short label folded into the file name to aid debugging.</param>
|
||||
public TempSecretStore(string label)
|
||||
{
|
||||
_path = Path.Combine(Path.GetTempPath(), $"zb-grpc-e2e-{label}-{Guid.NewGuid():N}.db");
|
||||
var factory = new SecretsSqliteConnectionFactory(_path);
|
||||
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
Store = new SqliteSecretStore(factory);
|
||||
}
|
||||
|
||||
/// <summary>The store.</summary>
|
||||
public SqliteSecretStore Store { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
foreach (string path in new[] { _path, _path + "-wal", _path + "-shm" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The central node: a real in-process ASP.NET Core host serving <see cref="SecretsHubService"/>
|
||||
/// through <c>AddZbSecretsGrpcHub</c> + <c>MapZbSecretsHub</c>, over
|
||||
/// <see cref="Microsoft.AspNetCore.TestHost.TestServer"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything the production composition root does happens here — options binding from
|
||||
/// configuration, the fail-closed interceptor registered as a singleton and attached to the service,
|
||||
/// routing, endpoint mapping — so a defect in the wiring shows up as a failing call rather than as a
|
||||
/// unit test that constructed the pieces by hand and never noticed they were never connected.
|
||||
/// </remarks>
|
||||
internal sealed class SecretsHubTestServer : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
|
||||
private SecretsHubTestServer(IHost host, CapturingLoggerProvider logs)
|
||||
{
|
||||
_host = host;
|
||||
Logs = logs;
|
||||
}
|
||||
|
||||
/// <summary>Everything the hub host logged.</summary>
|
||||
public CapturingLoggerProvider Logs { get; }
|
||||
|
||||
/// <summary>A handler that dials this server in process.</summary>
|
||||
/// <returns>The handler.</returns>
|
||||
public HttpMessageHandler CreateHandler() => _host.GetTestServer().CreateHandler();
|
||||
|
||||
/// <summary>
|
||||
/// Starts a hub registered exactly as a central node registers it.
|
||||
/// </summary>
|
||||
/// <param name="store">The store the hub serves.</param>
|
||||
/// <param name="bearerToken">The shared bearer token followers must present.</param>
|
||||
/// <param name="maxNamesPerRequest">Optional per-request name cap.</param>
|
||||
/// <returns>The running hub.</returns>
|
||||
public static Task<SecretsHubTestServer> StartAsync(
|
||||
ISecretStore store, string bearerToken, int? maxNamesPerRequest = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bearerToken);
|
||||
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:GrpcHub:BearerToken"] = bearerToken,
|
||||
};
|
||||
|
||||
if (maxNamesPerRequest is int cap)
|
||||
{
|
||||
settings["Secrets:GrpcHub:MaxNamesPerRequest"] =
|
||||
cap.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(settings)
|
||||
.Build();
|
||||
|
||||
return StartAsync(store, services => services.AddZbSecretsGrpcHub(configuration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a hub whose options carry <b>no</b> bearer token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>AddZbSecretsGrpcHub</c> refuses this configuration at registration — which is the intended
|
||||
/// first line of defence and is pinned elsewhere — so the pieces are wired here the way that
|
||||
/// extension wires them, minus the eager validation. That is precisely the case the interceptor's
|
||||
/// deny-all exists for: a host that built the options some other way. Without this path the
|
||||
/// second layer could only ever be tested against a hand-constructed interceptor, never over the
|
||||
/// wire.
|
||||
/// </remarks>
|
||||
/// <param name="store">The store the hub would serve.</param>
|
||||
/// <returns>The running hub.</returns>
|
||||
public static Task<SecretsHubTestServer> StartWithNoTokenConfiguredAsync(ISecretStore store) =>
|
||||
StartAsync(store, services =>
|
||||
{
|
||||
services.Configure<SecretsGrpcHubServerOptions>(options => options.BearerToken = string.Empty);
|
||||
services.AddSingleton<SecretsHubAuthInterceptor>();
|
||||
services.AddGrpc()
|
||||
.AddServiceOptions<SecretsHubService>(
|
||||
options => options.Interceptors.Add<SecretsHubAuthInterceptor>());
|
||||
});
|
||||
|
||||
private static async Task<SecretsHubTestServer> StartAsync(
|
||||
ISecretStore store, Action<IServiceCollection> registerHub)
|
||||
{
|
||||
var logs = new CapturingLoggerProvider();
|
||||
|
||||
IHost host = await new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.SetMinimumLevel(LogLevel.Trace);
|
||||
builder.AddProvider(logs);
|
||||
});
|
||||
services.AddRouting();
|
||||
services.AddSingleton(store);
|
||||
registerHub(services);
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints => endpoints.MapZbSecretsHub());
|
||||
});
|
||||
})
|
||||
.StartAsync();
|
||||
|
||||
return new SecretsHubTestServer(host, logs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _host.StopAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Teardown only: a host that has already faulted must not mask the assertion that ran
|
||||
// before it.
|
||||
}
|
||||
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The site node: the package's own client registration, dialling the in-process hub.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>AddZbSecretsGrpcHubClient</c> is called for real; the only substitution is a keyed
|
||||
/// <see cref="GrpcChannel"/> registered <b>before</b> it, which the extension's
|
||||
/// <c>TryAddKeyedSingleton</c> then leaves standing. So the generated client, the reader seam, the
|
||||
/// bearer header, the per-call deadline and the hosted sweep are all the shipped ones.
|
||||
/// </remarks>
|
||||
internal sealed class SecretsHubTestClient : IAsyncDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
private SecretsHubTestClient(
|
||||
ServiceProvider provider, CapturingLoggerProvider logs, List<SecretName> invalidated)
|
||||
{
|
||||
_provider = provider;
|
||||
Logs = logs;
|
||||
Invalidated = invalidated;
|
||||
Sweeper = provider.GetServices<IHostedService>().OfType<GrpcSecretSyncService>().Single();
|
||||
Hub = provider.GetRequiredService<ISecretsHubReader>();
|
||||
}
|
||||
|
||||
/// <summary>Everything the follower logged.</summary>
|
||||
public CapturingLoggerProvider Logs { get; }
|
||||
|
||||
/// <summary>The shipped pull-only sweep, resolved as a hosted service.</summary>
|
||||
public GrpcSecretSyncService Sweeper { get; }
|
||||
|
||||
/// <summary>The shipped hub reader, for the calls a sweep does not make on its own.</summary>
|
||||
public ISecretsHubReader Hub { get; }
|
||||
|
||||
/// <summary>Names the sweep evicted from the resolver cache.</summary>
|
||||
public List<SecretName> Invalidated { get; }
|
||||
|
||||
/// <summary>Builds a follower over the supplied transport.</summary>
|
||||
/// <param name="local">The node's local store.</param>
|
||||
/// <param name="handler">The transport reaching the hub.</param>
|
||||
/// <param name="bearerToken">The credential this node presents.</param>
|
||||
/// <param name="syncOnStartup">Whether the hosted sweep runs immediately on start.</param>
|
||||
/// <returns>The follower.</returns>
|
||||
public static SecretsHubTestClient Create(
|
||||
ISecretStore local,
|
||||
HttpMessageHandler handler,
|
||||
string bearerToken,
|
||||
bool syncOnStartup = true)
|
||||
{
|
||||
var logs = new CapturingLoggerProvider();
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:GrpcHub:Endpoint"] = "http://central.test",
|
||||
["Secrets:GrpcHub:BearerToken"] = bearerToken,
|
||||
// Long enough that the only sweeps in a test are the ones the test asks for.
|
||||
["Secrets:GrpcHub:SyncInterval"] = "01:00:00",
|
||||
["Secrets:GrpcHub:SyncOnStartup"] = syncOnStartup ? "true" : "false",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.SetMinimumLevel(LogLevel.Trace);
|
||||
builder.AddProvider(logs);
|
||||
});
|
||||
services.AddSingleton(local);
|
||||
|
||||
List<SecretName> invalidated = [];
|
||||
services.AddSingleton<ISecretCacheInvalidator>(new DelegatingCacheInvalidator(invalidated.Add));
|
||||
|
||||
// Registered ahead of the extension so its TryAddKeyedSingleton is a no-op: the endpoint in
|
||||
// configuration is never dialled, this in-process transport is.
|
||||
services.AddKeyedSingleton(
|
||||
SecretsGrpcHubClientExtensions.ChannelServiceKey,
|
||||
(_, _) => GrpcChannel.ForAddress(
|
||||
"http://central.test", new GrpcChannelOptions { HttpHandler = handler }));
|
||||
|
||||
services.AddZbSecretsGrpcHubClient(configuration);
|
||||
|
||||
return new SecretsHubTestClient(services.BuildServiceProvider(), logs, invalidated);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => _provider.DisposeAsync();
|
||||
|
||||
private sealed class DelegatingCacheInvalidator(Action<SecretName> onInvalidate) : ISecretCacheInvalidator
|
||||
{
|
||||
public void Invalidate(SecretName name) => onInvalidate(name);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@ internal sealed class FakeSecretsHubCallInvoker : CallInvoker
|
||||
/// <summary>The most recent <c>GetSecrets</c> request, so tests can assert what was asked for.</summary>
|
||||
public GetSecretsRequest? LastSecretsRequest { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, every call fails with this exception instead of returning a canned response —
|
||||
/// the transport half of the client's two failure kinds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A parse failure is contained per item; a transport failure must escape untouched, or an
|
||||
/// unreachable hub becomes indistinguishable from an empty one. Modelled as a factory so a test
|
||||
/// can hand back the same instance and assert identity, or a fresh one per call.
|
||||
/// </remarks>
|
||||
public Func<Exception>? FaultWith { get; set; }
|
||||
|
||||
/// <summary>Returns the <c>Authorization</c> header value seen on the call at the given index.</summary>
|
||||
/// <param name="index">Zero-based call index.</param>
|
||||
/// <returns>The header value, or <see langword="null"/> when the header was not sent.</returns>
|
||||
@@ -53,6 +64,18 @@ internal sealed class FakeSecretsHubCallInvoker : CallInvoker
|
||||
LastSecretsRequest = secretsRequest;
|
||||
}
|
||||
|
||||
if (FaultWith is not null)
|
||||
{
|
||||
// Faulted on the response task rather than thrown synchronously, because that is how a
|
||||
// real call reports a transport failure — the client has to let it out of an await.
|
||||
return new AsyncUnaryCall<TResponse>(
|
||||
Task.FromException<TResponse>(FaultWith()),
|
||||
Task.FromResult(new Metadata()),
|
||||
() => Status.DefaultSuccess,
|
||||
() => new Metadata(),
|
||||
() => { });
|
||||
}
|
||||
|
||||
object response = method.Name switch
|
||||
{
|
||||
"GetManifest" => ManifestResponse,
|
||||
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
|
||||
Reference in New Issue
Block a user