3e3748b985
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
70 lines
3.1 KiB
C#
70 lines
3.1 KiB
C#
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)];
|
|
}
|