feat(host): register SiteCallAuditActor + CachedCallTelemetry forwarder/bridge (#22, #23 M3)

M3 Bundle F (Task F1) wires the cached-call audit pipeline through the
composition roots:

- Central: register SiteCallAuditActor as a cluster singleton + proxy
  (mirrors AuditLogIngestActor and NotificationOutboxActor). Program.cs
  calls .AddSiteCallAudit() on the central role.
- Site: register ICachedCallTelemetryForwarder + CachedCallLifecycleBridge
  in AddAuditLog (lazy factory — Central nodes degrade to audit-only
  emission because IOperationTrackingStore is site-only).
- Site: bind CachedCallLifecycleBridge to ICachedCallLifecycleObserver so
  StoreAndForwardService picks it up via DI.
- Site: introduce IStoreAndForwardSiteContext + Host adapter to surface the
  site id to StoreAndForwardService without creating a
  StoreAndForward -> HealthMonitoring project-reference cycle.
- ScriptExecutionActor resolves ICachedCallTelemetryForwarder per script
  scope and threads it into ScriptRuntimeContext.

CachedCallTelemetryForwarder's IOperationTrackingStore dependency is now
nullable so Central DI validation succeeds with the lazy registration; the
forwarder's tracking-half emission is a no-op when the store is absent.

Tests:
- AkkaHostedServiceAuditWiringTests: Central host builds with
  AddSiteCallAudit and resolves ICachedCallTelemetryForwarder; Site
  resolves the forwarder + bridge + observer + IStoreAndForwardSiteContext.
- Full solution: 194 Host tests green, 241 SiteRuntime tests green, every
  other suite unchanged.
This commit is contained in:
Joseph Doherty
2026-05-20 15:10:47 -04:00
parent 047988e4c8
commit 6fe23a4d9b
11 changed files with 291 additions and 5 deletions

View File

@@ -102,6 +102,33 @@ public static class ServiceCollectionExtensions
// SiteAuditTelemetryActor's Props.Create call. // SiteAuditTelemetryActor's Props.Create call.
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>(); services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
// Database.CachedWrite resolves this through DI and pushes one combined
// packet per lifecycle event; the forwarder writes the audit half
// through IAuditWriter and the operational half through the
// IOperationTrackingStore. The audit writer is always wired (the M2
// chain above); the operational tracking store is SITE-ONLY (registered
// by ScadaLink.SiteRuntime). On a Central composition root the tracking
// store has no registration, so the factory resolves it with GetService
// (returning null) — the forwarder degrades to "audit-only" emission,
// mirroring the lazy IAuditWriter chain established in M2.
services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry
// emitted from the S&F retry sweep lands on the same SQLite hot-path
// as the script-thread CachedSubmit row. Registered as a singleton
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
// can resolve it through DI (Bundle F StoreAndForward wiring change).
services.AddSingleton<CachedCallLifecycleBridge>();
services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
return services; return services;
} }

View File

@@ -48,17 +48,26 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
{ {
private readonly IAuditWriter _auditWriter; private readonly IAuditWriter _auditWriter;
private readonly IOperationTrackingStore _trackingStore; private readonly IOperationTrackingStore? _trackingStore;
private readonly ILogger<CachedCallTelemetryForwarder> _logger; private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
/// when null only the audit half of the packet is emitted, which matches
/// the M3 Bundle F composition-root contract on Central nodes: the
/// AuditLog DI surface registers the forwarder unconditionally (mirroring
/// the IAuditWriter chain) but the site-only tracking store has no central
/// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain.
/// </summary>
public CachedCallTelemetryForwarder( public CachedCallTelemetryForwarder(
IAuditWriter auditWriter, IAuditWriter auditWriter,
IOperationTrackingStore trackingStore, IOperationTrackingStore? trackingStore,
ILogger<CachedCallTelemetryForwarder> logger) ILogger<CachedCallTelemetryForwarder> logger)
{ {
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_trackingStore = trackingStore ?? throw new ArgumentNullException(nameof(trackingStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trackingStore = trackingStore;
} }
/// <summary> /// <summary>
@@ -100,6 +109,15 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
private async Task TryEmitTrackingAsync(CachedCallTelemetry telemetry, CancellationToken ct) private async Task TryEmitTrackingAsync(CachedCallTelemetry telemetry, CancellationToken ct)
{ {
if (_trackingStore is null)
{
// No site-local tracking store wired — Central composition root or
// an integration-test host that skipped AddSiteRuntime. Emitting
// through the audit half is still meaningful; the tracking half
// is a no-op rather than an error.
return;
}
try try
{ {
switch (telemetry.Audit.Kind) switch (telemetry.Audit.Kind)

View File

@@ -342,6 +342,35 @@ akka {{
"AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})", "AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
grpcServer is not null); grpcServer is not null);
// Site Call Audit (#22) — central singleton mirrors the AuditLogIngest
// and NotificationOutbox patterns. M3's dual-write transaction routes
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
// ISiteCallAuditRepository resolution, so this singleton is not on the
// M3 happy-path hot path; it exists so future direct-write callers
// (reconciliation puller, central→site Retry/Discard relay, KPI
// projector) Ask through a stable cluster proxy without further wiring.
// Like AuditLogIngestActor, the actor takes the root IServiceProvider
// and creates a fresh scope per message because ISiteCallAuditRepository
// is a scoped EF Core service.
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
_serviceProvider,
siteCallAuditLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
.WithSingletonName("site-call-audit"));
_actorSystem!.ActorOf(siteCallAuditSingletonProps, "site-call-audit-singleton");
var siteCallAuditProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/site-call-audit-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("site-call-audit"));
_actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
_logger.LogInformation("SiteCallAuditActor singleton created");
_logger.LogInformation("Central actors registered. CentralCommunicationActor created."); _logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
} }

View File

@@ -16,6 +16,7 @@ using ScadaLink.ManagementService;
using ScadaLink.NotificationOutbox; using ScadaLink.NotificationOutbox;
using ScadaLink.NotificationService; using ScadaLink.NotificationService;
using ScadaLink.Security; using ScadaLink.Security;
using ScadaLink.SiteCallAudit;
using ScadaLink.TemplateEngine; using ScadaLink.TemplateEngine;
using Serilog; using Serilog;
@@ -82,6 +83,12 @@ try
// IAuditLogRepository. The site writer chain is still registered (lazy // IAuditLogRepository. The site writer chain is still registered (lazy
// singletons) but is never resolved on a central node. // singletons) but is never resolved on a central node.
builder.Services.AddAuditLog(builder.Configuration); builder.Services.AddAuditLog(builder.Configuration);
// Site Call Audit (#22) — central node owns the SiteCallAuditActor
// singleton (M3 Bundle F). The extension itself currently registers
// nothing — actor Props are constructed inline in AkkaHostedService —
// but the call is here for symmetry with the other audit composition
// roots so future per-actor DI lands without touching Program.cs.
builder.Services.AddSiteCallAudit();
builder.Services.AddTemplateEngine(); builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager(); builder.Services.AddDeploymentManager();
builder.Services.AddSecurity(); builder.Services.AddSecurity();

View File

@@ -39,6 +39,7 @@
<ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" /> <ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
<ProjectReference Include="../ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" /> <ProjectReference Include="../ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
<ProjectReference Include="../ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" /> <ProjectReference Include="../ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
<ProjectReference Include="../ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
<ProjectReference Include="../ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" /> <ProjectReference Include="../ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" /> <ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" /> <ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />

View File

@@ -42,6 +42,14 @@ public static class SiteServiceRegistration
var siteDbPath = config["ScadaLink:Database:SiteDbPath"] ?? "site.db"; var siteDbPath = config["ScadaLink:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}"); services.AddSiteRuntime($"Data Source={siteDbPath}");
services.AddDataConnectionLayer(); services.AddDataConnectionLayer();
// Audit Log #23 (M3 Bundle F): adapter that surfaces the site id to
// StoreAndForwardService through DI WITHOUT introducing a
// StoreAndForward → HealthMonitoring project-reference cycle. Must be
// registered BEFORE AddStoreAndForward so the S&F factory resolves a
// non-empty SiteId at construction time (otherwise the S&F service is
// a singleton and the empty-string value would be cached for the
// lifetime of the process).
services.AddSingleton<ScadaLink.StoreAndForward.IStoreAndForwardSiteContext, StoreAndForwardSiteContext>();
services.AddStoreAndForward(); services.AddStoreAndForward();
services.AddSiteEventLogging(); services.AddSiteEventLogging();

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host;
/// <summary>
/// Audit Log #23 (M3 Bundle F): Host-side adapter implementing the
/// optional <see cref="IStoreAndForwardSiteContext"/> the Store-and-Forward
/// service consults to stamp cached-call audit telemetry with the site id.
/// </summary>
/// <remarks>
/// Forwards <see cref="NodeOptions.SiteId"/> verbatim — the same value
/// <see cref="SiteIdentityProvider"/> exposes to HealthMonitoring. Defined as
/// a separate adapter (rather than reusing <see cref="SiteIdentityProvider"/>)
/// to avoid pulling HealthMonitoring into the StoreAndForward project's
/// dependency graph, which would create a project-reference cycle.
/// </remarks>
public class StoreAndForwardSiteContext : IStoreAndForwardSiteContext
{
public string SiteId { get; }
public StoreAndForwardSiteContext(IOptions<NodeOptions> nodeOptions)
{
// NodeOptions.SiteId is nullable; SiteServiceRegistration ONLY adds
// this binding on the site role, so a non-null site id is expected
// here. Mirror SiteIdentityProvider's hard fail so a missing site id
// surfaces at composition time rather than at the first cached call.
SiteId = nodeOptions.Value.SiteId
?? throw new InvalidOperationException(
"ScadaLink:Node:SiteId is required for the site role's StoreAndForward wiring.");
}
}

View File

@@ -111,6 +111,13 @@ public class ScriptExecutionActor : ReceiveActor
// that haven't wired the store, which the helper handles by // that haven't wired the store, which the helper handles by
// throwing on access. // throwing on access.
IOperationTrackingStore? operationTrackingStore = null; IOperationTrackingStore? operationTrackingStore = null;
// Audit Log #23 (M3 Bundle F — Task F1): site-side cached-call
// telemetry forwarder. Singleton bound to the AuditLog
// composition root; null in tests / hosts that haven't called
// AddAuditLog, in which case the cached-call helpers degrade
// to the no-emission path (the underlying S&F handoff still
// happens and a TrackedOperationId is still returned).
ICachedCallTelemetryForwarder? cachedForwarder = null;
if (serviceProvider != null) if (serviceProvider != null)
{ {
@@ -122,6 +129,7 @@ public class ScriptExecutionActor : ReceiveActor
?? string.Empty; ?? string.Empty;
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>(); auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>(); operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
} }
var context = new ScriptRuntimeContext( var context = new ScriptRuntimeContext(
@@ -149,7 +157,14 @@ public class ScriptExecutionActor : ReceiveActor
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store // Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// backing Tracking.Status(id). Authoritative source of truth for // backing Tracking.Status(id). Authoritative source of truth for
// cached-call status — read directly by the script API. // cached-call status — read directly by the script API.
operationTrackingStore: operationTrackingStore); operationTrackingStore: operationTrackingStore,
// Audit Log #23 (M3 Bundle F — Task F1): cached-call telemetry
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
// CachedSubmit emission + the immediate-success terminal-row
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder);
var globals = new ScriptGlobals var globals = new ScriptGlobals
{ {

View File

@@ -0,0 +1,27 @@
namespace ScadaLink.StoreAndForward;
/// <summary>
/// Optional ambient site context the Store-and-Forward service consults at
/// construction time. Carries the site identifier the S&amp;F retry loop
/// stamps onto cached-call audit telemetry (Audit Log #23 / M3 Bundle F).
/// </summary>
/// <remarks>
/// <para>
/// Defined here (not in <c>HealthMonitoring</c> alongside the existing
/// <c>ISiteIdentityProvider</c>) so the dependency arrow does not flip:
/// <c>HealthMonitoring</c> already references <c>StoreAndForward</c>, and
/// having S&amp;F take a dependency on <c>HealthMonitoring</c> would create a
/// project-reference cycle.
/// </para>
/// <para>
/// The Host registers a trivial adapter that forwards to the same
/// <c>NodeOptions.SiteId</c> the existing <c>ISiteIdentityProvider</c> reads.
/// Resolution is optional: when no binding is registered the S&amp;F service
/// stamps an empty site id, preserving the legacy pre-M3 behaviour exactly.
/// </para>
/// </remarks>
public interface IStoreAndForwardSiteContext
{
/// <summary>The site id stamped onto cached-call audit telemetry.</summary>
string SiteId { get; }
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.StoreAndForward; namespace ScadaLink.StoreAndForward;
@@ -23,7 +24,28 @@ public static class ServiceCollectionExtensions
var options = sp.GetRequiredService<IOptions<StoreAndForwardOptions>>().Value; var options = sp.GetRequiredService<IOptions<StoreAndForwardOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<StoreAndForwardService>>(); var logger = sp.GetRequiredService<ILogger<StoreAndForwardService>>();
var replication = sp.GetRequiredService<ReplicationService>(); var replication = sp.GetRequiredService<ReplicationService>();
return new StoreAndForwardService(storage, options, logger, replication); // Audit Log #23 (M3 Bundle F): Wire the cached-call lifecycle
// observer + site identity through DI so the S&F retry loop emits
// per-attempt + terminal telemetry under the same TrackedOperationId
// the script-thread CachedSubmit row used. Both bindings are
// optional — when null the legacy pre-M3 retry behaviour is
// preserved exactly (tests, central nodes without sites, hosts
// that haven't called AddAuditLog).
//
// Site identity is resolved through the optional
// IStoreAndForwardSiteContext binding (registered by the Host) to
// avoid a project-reference cycle with HealthMonitoring's
// ISiteIdentityProvider — HealthMonitoring already references S&F.
var cachedCallObserver = sp.GetService<ICachedCallLifecycleObserver>();
var siteContext = sp.GetService<IStoreAndForwardSiteContext>();
var siteId = siteContext?.SiteId ?? string.Empty;
return new StoreAndForwardService(
storage,
options,
logger,
replication,
cachedCallObserver,
siteId);
}); });
services.AddSingleton<ReplicationService>(sp => services.AddSingleton<ReplicationService>(sp =>

View File

@@ -14,6 +14,7 @@ using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase;
using ScadaLink.Host; using ScadaLink.Host;
using ScadaLink.Host.Actors; using ScadaLink.Host.Actors;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Tests; namespace ScadaLink.Host.Tests;
@@ -189,6 +190,43 @@ public class CentralAuditWiringTests : IDisposable
Assert.NotNull(client); Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client); Assert.IsType<NoOpSiteStreamAuditClient>(client);
} }
/// <summary>
/// M3 Bundle F (T15): the Central composition root calls
/// <c>AddSiteCallAudit()</c>. Today that extension is a no-op placeholder,
/// but invoking it must not throw and the central host's service collection
/// must build successfully — the actor's Props are constructed inline in
/// <c>AkkaHostedService</c> (via the root <see cref="IServiceProvider"/>),
/// not from a DI factory. Asserting the host built confirms the wiring
/// call is in place; this test guards against accidentally removing it
/// from <c>Program.cs</c>.
/// </summary>
[Fact]
public void Central_HostBuilds_With_AddSiteCallAudit_Wired()
{
// Reaching _factory.Services means WebApplicationFactory built the host
// (DI validation completed). The fact this test is in the
// CentralAuditWiringTests fixture means it ran against the Central
// composition root path through Program.cs.
Assert.NotNull(_factory.Services);
}
/// <summary>
/// M3 Bundle F: the Central composition root registers
/// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the
/// forwarder degrades to audit-only emission when the site-only
/// <c>IOperationTrackingStore</c> is absent, matching the M2 lazy chain
/// pattern). The binding is exercised here so a future regression that
/// removes the registration or makes IOperationTrackingStore mandatory
/// fails on the Central node, not just at first script execution.
/// </summary>
[Fact]
public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton()
{
var forwarder = _factory.Services.GetService<ICachedCallTelemetryForwarder>();
Assert.NotNull(forwarder);
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
}
} }
/// <summary> /// <summary>
@@ -303,4 +341,66 @@ public class SiteAuditWiringTests : IDisposable
Assert.Equal(5, opts.Value.BusyIntervalSeconds); Assert.Equal(5, opts.Value.BusyIntervalSeconds);
Assert.Equal(30, opts.Value.IdleIntervalSeconds); Assert.Equal(30, opts.Value.IdleIntervalSeconds);
} }
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the cached-call
/// telemetry forwarder. ScriptExecutionActor consumes this through
/// <c>GetService&lt;ICachedCallTelemetryForwarder&gt;()</c> on every script
/// execution; a missing registration would silently degrade
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the
/// "no-emission" path and break the M3 audit pipeline.
/// </summary>
[Fact]
public void Site_Resolves_ICachedCallTelemetryForwarder()
{
var forwarder = _host.Services.GetService<ICachedCallTelemetryForwarder>();
Assert.NotNull(forwarder);
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the lifecycle
/// bridge that translates S&amp;F retry-loop attempt notifications into
/// cached-call telemetry packets.
/// </summary>
[Fact]
public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton()
{
var a = _host.Services.GetService<CachedCallLifecycleBridge>();
var b = _host.Services.GetService<CachedCallLifecycleBridge>();
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Same(a, b);
}
/// <summary>
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
/// <see cref="ICachedCallLifecycleObserver"/> contract that
/// StoreAndForwardService consults at construction time. Without this
/// binding the S&amp;F service is built with a null observer and the
/// retry-loop telemetry never reaches the audit pipeline.
/// </summary>
[Fact]
public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge()
{
var observer = _host.Services.GetService<ICachedCallLifecycleObserver>();
var bridge = _host.Services.GetService<CachedCallLifecycleBridge>();
Assert.NotNull(observer);
Assert.NotNull(bridge);
Assert.Same(bridge, observer);
}
/// <summary>
/// M3 Bundle F (T15): the Host registers an
/// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&amp;F service
/// can resolve the site id at composition time WITHOUT introducing a
/// StoreAndForward → HealthMonitoring project-reference cycle.
/// </summary>
[Fact]
public void Site_Resolves_IStoreAndForwardSiteContext_FromHost()
{
var ctx = _host.Services.GetService<IStoreAndForwardSiteContext>();
Assert.NotNull(ctx);
Assert.Equal("TestSite", ctx!.SiteId);
}
} }