using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
///
/// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of
/// , but central's verification model is inverted:
/// it looks up the key for the site named in the x-scadabridge-site header and checks the
/// bearer token against THAT, rather than against one own-key.
///
///
/// Central genuinely needs a distinct verification model, so it is a separate class with its own
/// single public constructor — not a variant ctor on ,
/// whose two-public-constructor form once silently disabled the gate. The one-public-ctor
/// invariant is pinned below exactly as the sibling pins it.
///
public class CentralControlAuthInterceptorTests
{
private const string ControlMethod =
"/scadabridge.centralcontrol.v1.CentralControlService/SubmitNotification";
private const string SiteStreamMethod = "/sitestream.SiteStreamService/SubscribeInstance";
private const string SiteA = "site-a";
private const string SiteAKey = "site-a-preshared-key";
private const string SiteB = "site-b";
private const string SiteBKey = "site-b-preshared-key";
/// An backed by a fixed site→key map; throws for unknown sites.
private sealed class MapPskProvider(IReadOnlyDictionary keys) : ISitePskProvider
{
public ValueTask GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
public void Invalidate(string siteId) { }
}
private static CentralControlAuthInterceptor CreateInterceptor()
=> new(
new MapPskProvider(new Dictionary { [SiteA] = SiteAKey, [SiteB] = SiteBKey }),
NullLogger.Instance);
private static ServerCallContext CreateContext(
string method, string? siteHeader, string? authorizationHeader)
{
var headers = new Metadata();
if (siteHeader is not null)
headers.Add(ControlPlaneCredentials.SiteHeader, siteHeader);
if (authorizationHeader is not null)
headers.Add(ControlPlaneCredentials.AuthorizationHeader, authorizationHeader);
return new FakeServerCallContext(method, headers);
}
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
protected override string MethodCore => method;
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:12345";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
protected override Metadata RequestHeadersCore => requestHeaders;
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
protected override Metadata ResponseTrailersCore { get; } = [];
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary>());
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
private static Task Invoke(
CentralControlAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler(
"request", context, (_, _) => Task.FromResult("ok"));
[Theory]
[InlineData("notification")]
[InlineData("reconcile")]
[InlineData("health")]
[InlineData("heartbeat")]
[InlineData("audit")]
[InlineData("cached-audit")]
[InlineData("cached-operational")]
public async Task VerifiedSiteA_CannotSubmitSiteBOwnership(string kind)
{
var service = new CentralControlGrpcService(
NullLogger.Instance,
Microsoft.Extensions.Options.Options.Create(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions()));
object request = kind switch
{
"notification" => new NotificationSubmitDto { SourceSiteId = SiteB },
"reconcile" => new ReconcileSiteRequestDto { SiteIdentifier = SiteB },
"health" => new SiteHealthReportDto { SiteId = SiteB },
"heartbeat" => new HeartbeatDto { SiteId = SiteB },
"audit" => new AuditEventBatch { Events = { new AuditEventDto { SourceSiteId = SiteA }, new AuditEventDto { SourceSiteId = SiteB } } },
_ => new CachedTelemetryBatch { Packets = { new CachedTelemetryPacket {
AuditEvent = new AuditEventDto { SourceSiteId = kind == "cached-audit" ? SiteB : SiteA },
Operational = new SiteCallOperationalDto { SourceSite = kind == "cached-operational" ? SiteB : SiteA }
} } }
};
var continued = false;
var ex = await Assert.ThrowsAsync(() => CreateInterceptor().UnaryServerHandler