feat(auth): ScadaBridge inbound API — adopt ZB.MOM.WW.Auth.ApiKeys verifier + Bearer + scope=method (re-arch A+B); additive, old path retired later

This commit is contained in:
Joseph Doherty
2026-06-02 02:40:18 -04:00
parent 4db8c373af
commit a94558c289
7 changed files with 451 additions and 157 deletions
+4 -4
View File
@@ -80,10 +80,10 @@
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
</ItemGroup> </ItemGroup>
+38
View File
@@ -1,3 +1,4 @@
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Health; using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka; using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.Health.EntityFrameworkCore; using ZB.MOM.WW.Health.EntityFrameworkCore;
@@ -106,6 +107,43 @@ try
builder.Services.AddSecurity(builder.Configuration); builder.Services.AddSecurity(builder.Configuration);
builder.Services.AddCentralUI(); builder.Services.AddCentralUI();
builder.Services.AddInboundAPI(); builder.Services.AddInboundAPI();
// Inbound-API auth re-arch (A+B), additive: stand up the shared
// ZB.MOM.WW.Auth.ApiKeys verifier + SQLite store + startup migration
// ALONGSIDE the legacy peppered-HMAC X-API-Key path. The POST
// /api/{methodName} endpoint now authenticates Bearer tokens
// (sbk_<keyId>_<secret>) and authorizes by scope == method name through
// this verifier. The legacy ApiKeyValidator/IApiKeyHasher remain
// registered (unused by the endpoint) until a later sub-task retires the
// SQL Server ApiKey entity.
//
// ApiKeyOptions is an init-only record, so the contract-mandated values
// are injected as in-memory configuration UNDER the bound section path
// (ScadaBridge:InboundApi:ApiKeyStore) rather than mutated post-bind:
// - TokenPrefix = "sbk" (the inbound token prefix)
// - PepperSecretName points at the EXISTING inbound-API pepper config key
// (reused so one secret backs both the legacy and new path during the
// additive window)
// - RunMigrationsOnStartup = true (hosted service creates the schema)
// - SqlitePath defaults under the content root's data/ directory, but any
// value already supplied via appsettings/env wins (AddInMemoryCollection
// is registered last, but only fills keys the operator did not set
// because we read the existing value first).
const string apiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
var configuredSqlitePath = builder.Configuration[$"{apiKeyStoreSection}:SqlitePath"];
var apiKeyStoreDefaults = new Dictionary<string, string?>
{
[$"{apiKeyStoreSection}:TokenPrefix"] = "sbk",
[$"{apiKeyStoreSection}:PepperSecretName"] = "ScadaBridge:InboundApi:ApiKeyPepper",
[$"{apiKeyStoreSection}:RunMigrationsOnStartup"] = "true",
[$"{apiKeyStoreSection}:SqlitePath"] = string.IsNullOrWhiteSpace(configuredSqlitePath)
? Path.Combine(builder.Environment.ContentRootPath, "data", "inbound-api-keys.sqlite")
: configuredSqlitePath,
};
builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults);
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
builder.Services.AddManagementService(); builder.Services.AddManagementService();
var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"] var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"]
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Routing;
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 ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
@@ -18,6 +20,24 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// </summary> /// </summary>
public static class EndpointExtensions public static class EndpointExtensions
{ {
/// <summary>
/// Auth re-arch (A+B), InboundAPI-011 successor: the single message used for
/// BOTH "method not found" and "key not in scope for this method" so the two
/// outcomes are indistinguishable to the caller. A caller holding any valid key
/// must not be able to enumerate which method names exist by observing a
/// status/message difference, so both branches return 403 with this identical
/// body and the caller-supplied method name is never echoed back.
/// </summary>
private const string NotApprovedMessage = "API key not approved for this method";
/// <summary>
/// Auth re-arch (A+B): the generic 401 message. Every verifier failure reason
/// (missing/malformed token, unknown key, revoked key, pepper unavailable,
/// secret mismatch) maps to this one message so the auth stage is never leaked
/// to the caller.
/// </summary>
private const string UnauthorizedMessage = "Invalid or missing API key";
/// <summary>Registers the <c>POST /api/{methodName}</c> inbound API endpoint with the active-node gate and body-size filter applied.</summary> /// <summary>Registers the <c>POST /api/{methodName}</c> inbound API endpoint with the active-node gate and body-size filter applied.</summary>
/// <param name="endpoints">The route builder to add the endpoint to.</param> /// <param name="endpoints">The route builder to add the endpoint to.</param>
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints) public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
@@ -33,39 +53,65 @@ public static class EndpointExtensions
HttpContext httpContext, HttpContext httpContext,
string methodName) string methodName)
{ {
var logger = httpContext.RequestServices.GetRequiredService<ILogger<ApiKeyValidator>>(); var logger = httpContext.RequestServices.GetRequiredService<ILogger<IApiKeyVerifier>>();
var validator = httpContext.RequestServices.GetRequiredService<ApiKeyValidator>(); var verifier = httpContext.RequestServices.GetRequiredService<IApiKeyVerifier>();
var repository = httpContext.RequestServices.GetRequiredService<IInboundApiRepository>();
var executor = httpContext.RequestServices.GetRequiredService<InboundScriptExecutor>(); var executor = httpContext.RequestServices.GetRequiredService<InboundScriptExecutor>();
var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>(); var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>();
var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value; var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value;
// WP-1: Extract and validate API key // Auth re-arch (A+B): the inbound credential is now a Bearer token
var apiKeyValue = httpContext.Request.Headers["X-API-Key"].FirstOrDefault(); // (Authorization: Bearer sbk_<keyId>_<secret>) verified by the shared
var validationResult = await validator.ValidateAsync(apiKeyValue, methodName, httpContext.RequestAborted); // ZB.MOM.WW.Auth.ApiKeys verifier — peppered-HMAC constant-time secret
// compare is handled inside the library verifier. The raw X-API-Key header
// and the in-repo ApiKeyValidator are retired on this path.
var authorizationHeader = httpContext.Request.Headers.Authorization.ToString();
var verification = await verifier.VerifyAsync(
authorizationHeader, httpContext.RequestAborted);
if (!validationResult.IsValid) if (!verification.Succeeded)
{ {
// Telemetry follow-on: count every inbound request, including auth // WP-5: 401 for any verifier failure. The failure reason is
// failures. The raw {methodName} route value is arbitrary caller input // discriminated for our own logs/telemetry but NEVER surfaced to the
// and would be high-cardinality, so failures are tagged with a small // caller — every reason maps to the one generic message so the auth
// bounded set of sentinels keyed off the validator's status code rather // stage (missing vs unknown-key vs revoked vs secret-mismatch) is not
// than the unvalidated name (401 → "<unauthorized>", 403 → "<forbidden>"). // leaked.
ScadaBridgeTelemetry.RecordInboundApiRequest( ScadaBridgeTelemetry.RecordInboundApiRequest("<unauthorized>");
validationResult.StatusCode == StatusCodes.Status401Unauthorized
? "<unauthorized>"
: "<forbidden>");
// WP-5: Failures-only logging // WP-5: Failures-only logging.
logger.LogWarning( logger.LogWarning(
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})", "Inbound API auth failure for method {Method}: {Failure} (status 401)",
methodName, validationResult.ErrorMessage, validationResult.StatusCode); methodName, verification.Failure);
return Results.Json( return Results.Json(
new { error = validationResult.ErrorMessage }, new { error = UnauthorizedMessage },
statusCode: validationResult.StatusCode); statusCode: StatusCodes.Status401Unauthorized);
} }
var method = validationResult.Method!; var identity = verification.Identity!;
// Auth re-arch (A+B), enumeration-safety: "method not found" and "key not
// in scope for this method" MUST produce an indistinguishable response.
// The method is resolved for execution (script / parameters / timeout), but
// the not-found and not-in-scope branches both return 403 with the SAME
// body. Resolving the method first and folding both negatives into one
// branch keeps the two cases byte-identical (status + message), so a caller
// holding a valid key cannot probe which method names exist.
var method = await repository.GetMethodByNameAsync(methodName, httpContext.RequestAborted);
var inScope = identity.Scopes.Contains(methodName);
if (method == null || !inScope)
{
ScadaBridgeTelemetry.RecordInboundApiRequest("<forbidden>");
logger.LogWarning(
"Inbound API authz failure for method {Method}: not approved (status 403)",
methodName);
return Results.Json(
new { error = NotApprovedMessage },
statusCode: StatusCodes.Status403Forbidden);
}
// Telemetry follow-on: count this inbound request against the resolved, // Telemetry follow-on: count this inbound request against the resolved,
// registered method name. method.Name comes from the repository's method // registered method name. method.Name comes from the repository's method
@@ -73,12 +119,12 @@ public static class EndpointExtensions
// set of configured API methods — never the raw caller-supplied route value. // set of configured API methods — never the raw caller-supplied route value.
ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name); ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name);
// Audit Log (#23 M4 Bundle D): publish the resolved API key name so // Audit Log (#23 M4 Bundle D): publish the verified key's display name so
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally // AuditWriteMiddleware can populate AuditEvent.Actor in its finally
// block. Done AFTER validation succeeded — auth failures leave the // block. Done AFTER auth+authz succeeded — auth failures leave the
// slot empty and the middleware records the row with Actor=null. // slot empty and the middleware records the row with Actor=null.
httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] = httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
validationResult.ApiKey!.Name; identity.DisplayName;
// WP-2: Deserialize and validate parameters // WP-2: Deserialize and validate parameters
JsonElement? body = null; JsonElement? body = null;
@@ -25,6 +25,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
<!-- Inbound-API auth re-arch (A+B): the POST /api/{methodName} auth path is
served by the shared ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>,
scope = method name) instead of the legacy peppered-HMAC X-API-Key path.
AddZbApiKeyAuth (DI helper) lives in this package and brings the
Abstractions contracts (IApiKeyVerifier / ApiKeyOptions) transitively. -->
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,15 +1,20 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options;
using NSubstitute; using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
@@ -24,18 +29,22 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// was case-sensitive so a capitalised value silently skipped body parsing /// was case-sensitive so a capitalised value silently skipped body parsing
/// and any required parameters surfaced as a 400 even though the caller /// and any required parameters surfaced as a 400 even though the caller
/// sent a valid JSON body. /// sent a valid JSON body.
///
/// <para>
/// Auth re-arch (A+B): the request carries a Bearer token verified by the shared
/// ZB.MOM.WW.Auth.ApiKeys verifier (scope == method name), not the legacy X-API-Key
/// header. The content-type behaviour under test is downstream of auth and unchanged.
/// </para>
/// </summary> /// </summary>
public class EndpointContentTypeTests public sealed class EndpointContentTypeTests : IDisposable
{ {
/// <summary> private const string Pepper = "test-pepper-at-least-16-chars-long";
/// Stub hasher that returns its input unchanged. Lets the test pre-seed the private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper";
/// repository with a known "hash" value without depending on the real private const string TokenPrefix = "sbk";
/// HMAC-with-pepper hasher. private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
/// </summary>
private sealed class IdentityHasher : IApiKeyHasher private readonly string _sqlitePath =
{ Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite");
public string Hash(string keyValue) => keyValue;
}
[Theory] [Theory]
[InlineData("application/json")] [InlineData("application/json")]
@@ -44,13 +53,8 @@ public class EndpointContentTypeTests
[InlineData("APPLICATION/JSON")] [InlineData("APPLICATION/JSON")]
public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType) public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType)
{ {
const string apiKeyValue = "test-key";
const string methodName = "echoParam"; const string methodName = "echoParam";
var key = ApiKey.FromHash("test", apiKeyValue);
key.IsEnabled = true;
key.Id = 1;
var method = new ApiMethod(methodName, "return Parameters[\"value\"];") var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
{ {
Id = 1, Id = 1,
@@ -62,25 +66,22 @@ public class EndpointContentTypeTests
}; };
var repo = Substitute.For<IInboundApiRepository>(); var repo = Substitute.For<IInboundApiRepository>();
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()) repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
.Returns(method); .Returns(method);
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
using var host = await BuildHostAsync(repo); using var host = await BuildHostAsync(repo);
var token = await SeedKeyAsync(host, methodName);
var client = host.GetTestClient(); var client = host.GetTestClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{ {
// Bypass HttpClient's MediaTypeHeaderValue auto-normalization by // Bypass HttpClient's MediaTypeHeaderValue auto-normalization by
// setting the header through TryAddWithoutValidation — we need the // setting the header through MediaTypeHeaderValue.Parse — we need the
// exact casing reach the server intact. // exact casing to reach the server intact.
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")) Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}"))
}; };
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
request.Headers.Add("X-API-Key", apiKeyValue); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@@ -91,33 +92,56 @@ public class EndpointContentTypeTests
Assert.Contains("42", body); Assert.Contains("42", body);
} }
private static async Task<IHost> BuildHostAsync(IInboundApiRepository repo) /// <summary>Seeds a key scoped for <paramref name="methodName"/> and returns its Bearer token.</summary>
private static async Task<string> SeedKeyAsync(IHost host, string methodName)
{ {
var services = host.Services;
var commands = new ApiKeyAdminCommands(
services.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
services.GetRequiredService<IApiKeyAdminStore>(),
services.GetRequiredService<IApiKeyAuditStore>(),
services.GetRequiredService<IApiKeyPepperProvider>(),
services.GetRequiredService<SqliteAuthStoreMigrator>());
var result = await commands.CreateKeyAsync(
"key1", "ct-caller", new HashSet<string> { methodName },
constraintsJson: null, remoteAddress: null, CancellationToken.None);
return result.Token!;
}
private async Task<IHost> BuildHostAsync(IInboundApiRepository repo)
{
// The pepper provider reads the HOST's IConfiguration (AddZbApiKeyAuth only
// TryAdds its own), so the api-key settings — pepper included — must live in
// the host configuration.
var apiKeySettings = new Dictionary<string, string?>
{
[PepperConfigKey] = Pepper,
[$"{ApiKeyStoreSection}:TokenPrefix"] = TokenPrefix,
[$"{ApiKeyStoreSection}:PepperSecretName"] = PepperConfigKey,
[$"{ApiKeyStoreSection}:SqlitePath"] = _sqlitePath,
[$"{ApiKeyStoreSection}:RunMigrationsOnStartup"] = "true",
};
var hostBuilder = new HostBuilder() var hostBuilder = new HostBuilder()
.ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings))
.ConfigureWebHost(webBuilder => .ConfigureWebHost(webBuilder =>
{ {
webBuilder webBuilder
.UseTestServer() .UseTestServer()
.ConfigureServices(services => .ConfigureServices((context, services) =>
{ {
services.AddRouting(); services.AddRouting();
services.AddSingleton(repo); services.AddSingleton(repo);
// RouteHelper depends on IInstanceLocator + IInstanceRouter // RouteHelper depends on IInstanceLocator + IInstanceRouter
// (InboundAPI-017). Tests for content-type handling never // (InboundAPI-017). Tests for content-type handling never
// route, so both can be no-op stubs — the production // route, so both can be no-op stubs.
// CommunicationServiceInstanceRouter would need a real
// CommunicationService which isn't wired here.
services.AddSingleton(Substitute.For<IInstanceLocator>()); services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { }); services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI(); services.AddInboundAPI();
services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection);
services.RemoveAll<IInstanceRouter>(); services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>()); services.AddSingleton(Substitute.For<IInstanceRouter>());
// The production AddInboundAPI registration of IApiKeyHasher
// requires a configured pepper. Replace it with the identity
// stub so the seeded ApiKey.KeyHash matches "test-key"
// deterministically without depending on configuration.
services.RemoveAll<IApiKeyHasher>();
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
services.AddLogging(); services.AddLogging();
}) })
.Configure(app => .Configure(app =>
@@ -129,4 +153,24 @@ public class EndpointContentTypeTests
return await hostBuilder.StartAsync(); return await hostBuilder.StartAsync();
} }
public void Dispose()
{
try
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
var path = _sqlitePath + suffix;
if (File.Exists(path))
{
File.Delete(path);
}
}
}
catch
{
// Best-effort cleanup.
}
}
} }
@@ -2,50 +2,66 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NSubstitute; using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Text; using System.Text;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary> /// <summary>
/// InboundAPI-023: <see cref="EndpointExtensions.HandleInboundApiRequest"/> is /// Auth re-arch (A+B): <see cref="EndpointExtensions.HandleInboundApiRequest"/> now
/// the composition wiring that ties validator → JSON parse → ParameterValidator → /// authenticates with the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> verifier — a Bearer
/// InboundScriptExecutor → response shaping together. Each composed component /// token (<c>Authorization: Bearer sbk_&lt;keyId&gt;_&lt;secret&gt;</c>) replaces the
/// has its own unit tests, but the wiring itself was uncovered. These tests /// raw X-API-Key header, and a key's <c>Scopes</c> set (the API-method names it may
/// drive the end-to-end POST /api/{methodName} flow through a TestServer so a /// call) replaces the per-method approval table for authorization. These tests drive
/// regression in any of the seams below would be caught here: /// the end-to-end POST /api/{methodName} flow through a TestServer, seeding the
/// library SQLite store via <see cref="ApiKeyAdminCommands"/>, so a regression in any
/// of the seams below would be caught here:
/// ///
/// 1. happy path — 200 + script result body /// 1. happy path — valid Bearer + in-scope → 200 + script result body.
/// 2. auth failures — validator status code propagates verbatim /// 2. valid key, method NOT in scope → 403 (authz).
/// 3. invalid JSON body — 400 + sanitized error /// 3. unknown method → 403 with the SAME body as not-in-scope
/// 4. parameter validation failure — 400 + ParameterValidator's error message /// (enumeration-safety — neither the status nor the message reveals which).
/// 5. script failure — 500 + ErrorMessage in body /// 4. missing / garbage Bearer → 401 (generic, no stage leak).
/// 6. successful auth must publish the resolved API key name into /// 5. revoked key → 401.
/// 6. invalid JSON body → 400 + sanitized error.
/// 7. parameter validation failure → 400 + ParameterValidator's error message.
/// 8. script failure → 500 + ErrorMessage in body.
/// 9. successful auth must publish the verified key's DISPLAY NAME into
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> (so the /// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> (so the
/// AuditWriteMiddleware sees a non-null Actor when it emits the audit row). /// AuditWriteMiddleware sees a non-null Actor when it emits the audit row).
/// </summary> /// </summary>
public class EndpointExtensionsTests public sealed class EndpointExtensionsTests : IDisposable
{ {
/// <summary> // The pepper used to back the seeded keys. The verifier resolves it from
/// Stub hasher that returns its input unchanged. Same pattern as // configuration under the name ApiKeyOptions.PepperSecretName; the seed path
/// <see cref="EndpointContentTypeTests"/> — lets us seed an ApiKey with a // resolves it through the same IApiKeyPepperProvider, so both agree.
/// known "hash" without depending on the configured HMAC pepper. private const string Pepper = "test-pepper-at-least-16-chars-long";
/// </summary> private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper";
private sealed class IdentityHasher : IApiKeyHasher private const string TokenPrefix = "sbk";
{ private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
public string Hash(string keyValue) => keyValue;
} // Each test gets its own throwaway SQLite database so seeded keys never leak
// between tests; the file is deleted on Dispose.
private readonly string _sqlitePath =
Path.Combine(Path.GetTempPath(), $"inbound-api-keys-{Guid.NewGuid():N}.sqlite");
/// <summary> /// <summary>
/// Inline middleware that captures the value at /// Inline middleware that captures the value at
@@ -58,16 +74,6 @@ public class EndpointExtensionsTests
public string? CapturedActor { get; set; } public string? CapturedActor { get; set; }
} }
private const string ApiKeyValue = "test-key";
private static ApiKey SeedKey(int id = 1, string name = "test")
{
var key = ApiKey.FromHash(name, ApiKeyValue);
key.IsEnabled = true;
key.Id = id;
return key;
}
private static ApiMethod SeedMethod( private static ApiMethod SeedMethod(
int id, string name, string script, string? paramDefs = null) int id, string name, string script, string? paramDefs = null)
{ {
@@ -80,16 +86,18 @@ public class EndpointExtensionsTests
} }
[Fact] [Fact]
public async Task HappyPath_Returns200WithScriptResultJson() public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson()
{ {
var key = SeedKey();
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]"""); """[{"name":"value","type":"Integer","required":true}]""");
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
// Seed a key whose scope set contains the method name "echo".
var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller",
scopes: new[] { "echo" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var request = BuildPost("echo", """{"value":7}"""); var request = BuildPost("echo", """{"value":7}""", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@@ -98,15 +106,15 @@ public class EndpointExtensionsTests
} }
[Fact] [Fact]
public async Task MissingApiKey_Returns401() public async Task MissingBearer_Returns401()
{ {
var key = SeedKey();
var method = SeedMethod(1, "noKey", "return 1;"); var method = SeedMethod(1, "noKey", "return 1;");
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" });
var client = host.GetTestClient(); var client = host.GetTestClient();
// No X-API-Key header — auth should reject with 401. // No Authorization header — auth should reject with 401.
var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey") var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
{ {
Content = new StringContent("{}", Encoding.UTF8, "application/json"), Content = new StringContent("{}", Encoding.UTF8, "application/json"),
@@ -117,32 +125,104 @@ public class EndpointExtensionsTests
} }
[Fact] [Fact]
public async Task UnknownMethod_Returns403_IndistinguishableFromNotApproved() public async Task GarbageBearer_Returns401()
{ {
// InboundAPI-011: method existence is intentionally not observable — var method = SeedMethod(1, "noKey", "return 1;");
// both "method not found" and "key not approved" surface as 403.
var key = SeedKey();
var method = SeedMethod(1, "knownMethod", "return 1;");
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var request = BuildPost("unknownMethod", "{}"); // A well-formed Authorization header carrying a non-parseable / wrong-prefix
// token is indistinguishable from a missing credential — still 401.
var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-real-token");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task RevokedKey_Returns401()
{
var method = SeedMethod(1, "echo", "return 1;");
using var host = await BuildHostAsync(method);
// Seed-then-revoke: the token is well-formed and the secret matches, but
// the key is revoked — the verifier fails closed and the endpoint maps it
// to the generic 401 (no "revoked" stage leak).
var token = await SeedKeyAsync(host, "key1", "echo-caller", new[] { "echo" });
await RevokeKeyAsync(host, "key1");
var client = host.GetTestClient();
var request = BuildPost("echo", "{}", token);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task ValidKey_MethodNotInScope_Returns403()
{
// The key authenticates fine, but its scope set does NOT contain the
// requested method name — authorization fails with 403.
var method = SeedMethod(1, "secured", "return 1;");
using var host = await BuildHostAsync(method);
// Scope grants "otherMethod", not "secured".
var token = await SeedKeyAsync(host, "key1", "limited-caller", new[] { "otherMethod" });
var client = host.GetTestClient();
var request = BuildPost("secured", "{}", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
} }
[Fact] [Fact]
public async Task InvalidJsonBody_Returns400() public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope()
{ {
var key = SeedKey(); // Enumeration-safety: "method not found" and "key not in scope" must be
var method = SeedMethod(1, "badJson", "return 1;"); // indistinguishable — same 403 status AND same response body. We drive both
// cases through the same valid key and assert byte-identical bodies.
var method = SeedMethod(1, "knownMethod", "return 1;");
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
// Key is in scope for "knownMethod" only — so:
// - posting "unknownMethod" → method-not-found 403
// - posting "knownMethod" with a key scoped elsewhere would be not-in-scope 403
var unknownToken = await SeedKeyAsync(host, "key1", "caller", new[] { "unknownMethod" });
var notInScopeToken = await SeedKeyAsync(host, "key2", "caller2", new[] { "somethingElse" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var request = BuildPost("badJson", "{ not json"); // (a) unknown method — the key IS in scope for "unknownMethod" (so scope
// passes) but no such method exists in the repository → method-not-found 403.
var unknownResponse = await client.SendAsync(BuildPost("unknownMethod", "{}", unknownToken));
var unknownBody = await unknownResponse.Content.ReadAsStringAsync();
// (b) known method but key not in scope for it → not-in-scope 403.
var notInScopeResponse = await client.SendAsync(BuildPost("knownMethod", "{}", notInScopeToken));
var notInScopeBody = await notInScopeResponse.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, unknownResponse.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, notInScopeResponse.StatusCode);
// The crux of the enumeration-safety invariant: identical bodies.
Assert.Equal(notInScopeBody, unknownBody);
}
[Fact]
public async Task InvalidJsonBody_Returns400()
{
var method = SeedMethod(1, "badJson", "return 1;");
using var host = await BuildHostAsync(method);
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "badJson" });
var client = host.GetTestClient();
var request = BuildPost("badJson", "{ not json", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@@ -153,15 +233,15 @@ public class EndpointExtensionsTests
[Fact] [Fact]
public async Task MissingRequiredParameter_Returns400_FromParameterValidator() public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
{ {
var key = SeedKey();
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];", var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]"""); """[{"name":"value","type":"Integer","required":true}]""");
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "needsParam" });
var client = host.GetTestClient(); var client = host.GetTestClient();
// Body is empty object — required parameter "value" is missing. // Body is empty object — required parameter "value" is missing.
var request = BuildPost("needsParam", "{}"); var request = BuildPost("needsParam", "{}", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@@ -173,19 +253,19 @@ public class EndpointExtensionsTests
[Fact] [Fact]
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody() public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
{ {
var key = SeedKey();
// Throws inside the script body — InboundScriptExecutor catches the // Throws inside the script body — InboundScriptExecutor catches the
// exception, logs it server-side, and surfaces the generic "Internal // exception, logs it server-side, and surfaces the generic "Internal
// script error" message to the caller (the executor deliberately does // script error" message to the caller (the executor deliberately does
// not leak raw exception details — see InboundScriptExecutor.ExecuteAsync's // not leak raw exception details). The endpoint maps the script failure
// catch block). The endpoint maps the script failure to HTTP 500. // to HTTP 500.
var method = SeedMethod(1, "boom", var method = SeedMethod(1, "boom",
"""throw new System.InvalidOperationException("boom-msg");"""); """throw new System.InvalidOperationException("boom-msg");""");
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "boom" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var request = BuildPost("boom", "{}"); var request = BuildPost("boom", "{}", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@@ -197,19 +277,18 @@ public class EndpointExtensionsTests
} }
[Fact] [Fact]
public async Task SuccessfulAuth_StashesResolvedApiKeyNameOnHttpContextItems() public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems()
{ {
// InboundAPI-023: the handler stashes the resolved API key's display name // The handler stashes the VERIFIED key's display name at
// at HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth // HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth+authz
// succeeded, so AuditWriteMiddleware sees a populated Actor when it // succeeded, so AuditWriteMiddleware sees a populated Actor when it emits
// emits the audit row. A capture middleware reads the slot once the // the audit row. A capture middleware reads the slot once the endpoint
// endpoint finishes, proving the wiring still publishes it. // finishes, proving the wiring still publishes it.
var key = SeedKey(id: 99, name: "audit-actor-name");
var method = SeedMethod(1, "stamp", "return 1;"); var method = SeedMethod(1, "stamp", "return 1;");
var capture = new AuditActorCapture(); var capture = new AuditActorCapture();
using var host = await BuildHostAsync(key, method, customize: builder => using var host = await BuildHostAsync(method, customize: builder =>
{ {
builder.Use(async (ctx, next) => builder.Use(async (ctx, next) =>
{ {
@@ -225,9 +304,10 @@ public class EndpointExtensionsTests
{ {
services.AddSingleton(capture); services.AddSingleton(capture);
}); });
var token = await SeedKeyAsync(host, "key1", "audit-actor-name", new[] { "stamp" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var request = BuildPost("stamp", "{}"); var request = BuildPost("stamp", "{}", token);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -241,16 +321,16 @@ public class EndpointExtensionsTests
// scadabridge.inbound_api.requests once, tagged with the resolved, // scadabridge.inbound_api.requests once, tagged with the resolved,
// registered method name (method.Name) — the bounded identifier, not the // registered method name (method.Name) — the bounded identifier, not the
// raw route value. // raw route value.
var key = SeedKey();
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]"""); """[{"name":"value","type":"Integer","required":true}]""");
using var collector = new InboundApiRequestCounterCollector(); using var collector = new InboundApiRequestCounterCollector();
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "echo" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var response = await client.SendAsync(BuildPost("echo", """{"value":7}""")); var response = await client.SendAsync(BuildPost("echo", """{"value":7}""", token));
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Filter by the method tag this test produced: the counter is a process-wide // Filter by the method tag this test produced: the counter is a process-wide
@@ -264,19 +344,21 @@ public class EndpointExtensionsTests
[Fact] [Fact]
public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel() public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
{ {
// Telemetry follow-on: an auth/authz failure is still counted, but the // Telemetry follow-on: an authz failure is still counted, but the tag is a
// tag is a bounded sentinel ("<forbidden>") rather than the arbitrary // bounded sentinel ("<forbidden>") rather than the arbitrary caller-supplied
// caller-supplied route value — so an attacker posting random method // route value — so an attacker posting random method names cannot blow up
// names cannot blow up the `method` tag cardinality. // the `method` tag cardinality.
var key = SeedKey();
var method = SeedMethod(1, "knownMethod", "return 1;"); var method = SeedMethod(1, "knownMethod", "return 1;");
using var collector = new InboundApiRequestCounterCollector(); using var collector = new InboundApiRequestCounterCollector();
using var host = await BuildHostAsync(key, method); using var host = await BuildHostAsync(method);
// Key is in scope for the made-up name, so scope passes and the request
// falls through to method-not-found (403) — exercising the forbidden path.
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "totally-made-up-name" });
var client = host.GetTestClient(); var client = host.GetTestClient();
var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}")); var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}", token));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var measurements = collector.Measurements; var measurements = collector.Measurements;
@@ -342,49 +424,100 @@ public class EndpointExtensionsTests
public void Dispose() => _listener.Dispose(); public void Dispose() => _listener.Dispose();
} }
private static HttpRequestMessage BuildPost(string methodName, string body) private static HttpRequestMessage BuildPost(string methodName, string body, string bearerToken)
{ {
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{ {
Content = new StringContent(body, Encoding.UTF8, "application/json"), Content = new StringContent(body, Encoding.UTF8, "application/json"),
}; };
request.Headers.Add("X-API-Key", ApiKeyValue); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
return request; return request;
} }
private static async Task<IHost> BuildHostAsync( /// <summary>
ApiKey key, /// Seeds a key with the given scopes into the library SQLite store via
/// <see cref="ApiKeyAdminCommands.CreateKeyAsync"/> (the public admin seam) and
/// returns the assembled Bearer token (<c>sbk_&lt;keyId&gt;_&lt;secret&gt;</c>) —
/// the only moment the secret is ever available. The verifier registered in the
/// host's DI shares the same SQLite path + pepper, so it accepts this token.
/// </summary>
private static async Task<string> SeedKeyAsync(
IHost host, string keyId, string displayName, IReadOnlyCollection<string> scopes)
{
var commands = BuildAdminCommands(host);
var result = await commands.CreateKeyAsync(
keyId, displayName, new HashSet<string>(scopes),
constraintsJson: null, remoteAddress: null, CancellationToken.None);
Assert.NotNull(result.Token);
return result.Token!;
}
private static async Task RevokeKeyAsync(IHost host, string keyId)
{
var commands = BuildAdminCommands(host);
await commands.RevokeKeyAsync(keyId, remoteAddress: null, CancellationToken.None);
}
/// <summary>
/// Builds an <see cref="ApiKeyAdminCommands"/> over the stores + pepper provider +
/// migrator that AddZbApiKeyAuth registered in the host's DI, so seeding writes to
/// the exact same database/pepper the verifier reads from.
/// </summary>
private static ApiKeyAdminCommands BuildAdminCommands(IHost host)
{
var services = host.Services;
return new ApiKeyAdminCommands(
services.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
services.GetRequiredService<IApiKeyAdminStore>(),
services.GetRequiredService<IApiKeyAuditStore>(),
services.GetRequiredService<IApiKeyPepperProvider>(),
services.GetRequiredService<SqliteAuthStoreMigrator>());
}
private async Task<IHost> BuildHostAsync(
ApiMethod method, ApiMethod method,
Action<IApplicationBuilder>? customize = null, Action<IApplicationBuilder>? customize = null,
Action<IServiceCollection>? additionalServices = null) Action<IServiceCollection>? additionalServices = null)
{ {
var repo = Substitute.For<IInboundApiRepository>(); var repo = Substitute.For<IInboundApiRepository>();
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>()) repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
.Returns(method); .Returns(method);
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key }); // The pepper provider (ConfigurationApiKeyPepperProvider) reads the HOST's
// IConfiguration, and AddZbApiKeyAuth only TryAdds its own config (so the
// host registration wins). The api-key settings — including the pepper —
// must therefore live in the host configuration, not a separate object.
var apiKeySettings = new Dictionary<string, string?>
{
[PepperConfigKey] = Pepper,
[$"{ApiKeyStoreSection}:TokenPrefix"] = TokenPrefix,
[$"{ApiKeyStoreSection}:PepperSecretName"] = PepperConfigKey,
[$"{ApiKeyStoreSection}:SqlitePath"] = _sqlitePath,
[$"{ApiKeyStoreSection}:RunMigrationsOnStartup"] = "true",
};
var hostBuilder = new HostBuilder() var hostBuilder = new HostBuilder()
.ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings))
.ConfigureWebHost(webBuilder => .ConfigureWebHost(webBuilder =>
{ {
webBuilder webBuilder
.UseTestServer() .UseTestServer()
.ConfigureServices(services => .ConfigureServices((context, services) =>
{ {
services.AddRouting(); services.AddRouting();
services.AddSingleton(repo); services.AddSingleton(repo);
services.AddSingleton(Substitute.For<IInstanceLocator>()); services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { }); services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI(); services.AddInboundAPI();
// Replace the production CommunicationService-backed // Stand up the shared verifier + SQLite store + migration
// router and the configured HMAC hasher with test stubs // hosted service against the per-test database and pepper,
// (same pattern as EndpointContentTypeTests). // binding from the host configuration the pepper provider reads.
services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection);
// Replace the production CommunicationService-backed router
// with a test stub (it would otherwise need a real
// CommunicationService which isn't wired here).
services.RemoveAll<IInstanceRouter>(); services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>()); services.AddSingleton(Substitute.For<IInstanceRouter>());
services.RemoveAll<IApiKeyHasher>();
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
services.AddLogging(); services.AddLogging();
additionalServices?.Invoke(services); additionalServices?.Invoke(services);
}) })
@@ -398,4 +531,26 @@ public class EndpointExtensionsTests
return await hostBuilder.StartAsync(); return await hostBuilder.StartAsync();
} }
public void Dispose()
{
try
{
// SqliteConnection pooling can hold the file open; clear pools before
// deleting so the temp DB (and its -wal/-shm sidecars) are removed.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
var path = _sqlitePath + suffix;
if (File.Exists(path))
{
File.Delete(path);
}
}
}
catch
{
// Best-effort cleanup; a leaked temp file is harmless.
}
}
} }
@@ -19,6 +19,11 @@
<PackageReference Include="NSubstitute" /> <PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
<!-- Auth re-arch (A+B): endpoint-auth tests seed the shared library SQLite
store (ApiKeyAdminCommands.CreateKeyAsync) and present the assembled
Bearer token, then assert the verifier-backed POST /api/{methodName}
auth path (401 / 403 / 200 + scope=method). -->
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>