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:
@@ -2,50 +2,66 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
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.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-023: <see cref="EndpointExtensions.HandleInboundApiRequest"/> is
|
||||
/// the composition wiring that ties validator → JSON parse → ParameterValidator →
|
||||
/// InboundScriptExecutor → response shaping together. Each composed component
|
||||
/// has its own unit tests, but the wiring itself was uncovered. These tests
|
||||
/// drive the end-to-end POST /api/{methodName} flow through a TestServer so a
|
||||
/// regression in any of the seams below would be caught here:
|
||||
/// Auth re-arch (A+B): <see cref="EndpointExtensions.HandleInboundApiRequest"/> now
|
||||
/// authenticates with the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> verifier — a Bearer
|
||||
/// token (<c>Authorization: Bearer sbk_<keyId>_<secret></c>) replaces the
|
||||
/// raw X-API-Key header, and a key's <c>Scopes</c> set (the API-method names it may
|
||||
/// call) replaces the per-method approval table for authorization. These tests drive
|
||||
/// 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
|
||||
/// 2. auth failures — validator status code propagates verbatim
|
||||
/// 3. invalid JSON body — 400 + sanitized error
|
||||
/// 4. parameter validation failure — 400 + ParameterValidator's error message
|
||||
/// 5. script failure — 500 + ErrorMessage in body
|
||||
/// 6. successful auth must publish the resolved API key name into
|
||||
/// 1. happy path — valid Bearer + in-scope → 200 + script result body.
|
||||
/// 2. valid key, method NOT in scope → 403 (authz).
|
||||
/// 3. unknown method → 403 with the SAME body as not-in-scope
|
||||
/// (enumeration-safety — neither the status nor the message reveals which).
|
||||
/// 4. missing / garbage Bearer → 401 (generic, no stage leak).
|
||||
/// 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
|
||||
/// AuditWriteMiddleware sees a non-null Actor when it emits the audit row).
|
||||
/// </summary>
|
||||
public class EndpointExtensionsTests
|
||||
public sealed class EndpointExtensionsTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Stub hasher that returns its input unchanged. Same pattern as
|
||||
/// <see cref="EndpointContentTypeTests"/> — lets us seed an ApiKey with a
|
||||
/// known "hash" without depending on the configured HMAC pepper.
|
||||
/// </summary>
|
||||
private sealed class IdentityHasher : IApiKeyHasher
|
||||
{
|
||||
public string Hash(string keyValue) => keyValue;
|
||||
}
|
||||
// The pepper used to back the seeded keys. The verifier resolves it from
|
||||
// configuration under the name ApiKeyOptions.PepperSecretName; the seed path
|
||||
// resolves it through the same IApiKeyPepperProvider, so both agree.
|
||||
private const string Pepper = "test-pepper-at-least-16-chars-long";
|
||||
private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper";
|
||||
private const string TokenPrefix = "sbk";
|
||||
private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
|
||||
|
||||
// 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>
|
||||
/// Inline middleware that captures the value at
|
||||
@@ -58,16 +74,6 @@ public class EndpointExtensionsTests
|
||||
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(
|
||||
int id, string name, string script, string? paramDefs = null)
|
||||
{
|
||||
@@ -80,16 +86,18 @@ public class EndpointExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HappyPath_Returns200WithScriptResultJson()
|
||||
public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson()
|
||||
{
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
|
||||
"""[{"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 request = BuildPost("echo", """{"value":7}""");
|
||||
var request = BuildPost("echo", """{"value":7}""", token);
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -98,15 +106,15 @@ public class EndpointExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingApiKey_Returns401()
|
||||
public async Task MissingBearer_Returns401()
|
||||
{
|
||||
var key = SeedKey();
|
||||
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();
|
||||
|
||||
// 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")
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
@@ -117,32 +125,104 @@ public class EndpointExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownMethod_Returns403_IndistinguishableFromNotApproved()
|
||||
public async Task GarbageBearer_Returns401()
|
||||
{
|
||||
// InboundAPI-011: method existence is intentionally not observable —
|
||||
// both "method not found" and "key not approved" surface as 403.
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "knownMethod", "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 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);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidJsonBody_Returns400()
|
||||
public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope()
|
||||
{
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "badJson", "return 1;");
|
||||
// Enumeration-safety: "method not found" and "key not in scope" must be
|
||||
// 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 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 body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -153,15 +233,15 @@ public class EndpointExtensionsTests
|
||||
[Fact]
|
||||
public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
|
||||
{
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
|
||||
"""[{"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();
|
||||
|
||||
// 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 body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -173,19 +253,19 @@ public class EndpointExtensionsTests
|
||||
[Fact]
|
||||
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
|
||||
{
|
||||
var key = SeedKey();
|
||||
// Throws inside the script body — InboundScriptExecutor catches the
|
||||
// exception, logs it server-side, and surfaces the generic "Internal
|
||||
// script error" message to the caller (the executor deliberately does
|
||||
// not leak raw exception details — see InboundScriptExecutor.ExecuteAsync's
|
||||
// catch block). The endpoint maps the script failure to HTTP 500.
|
||||
// not leak raw exception details). The endpoint maps the script failure
|
||||
// to HTTP 500.
|
||||
var method = SeedMethod(1, "boom",
|
||||
"""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 request = BuildPost("boom", "{}");
|
||||
var request = BuildPost("boom", "{}", token);
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -197,19 +277,18 @@ public class EndpointExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessfulAuth_StashesResolvedApiKeyNameOnHttpContextItems()
|
||||
public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems()
|
||||
{
|
||||
// InboundAPI-023: the handler stashes the resolved API key's display name
|
||||
// at HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth
|
||||
// succeeded, so AuditWriteMiddleware sees a populated Actor when it
|
||||
// emits the audit row. A capture middleware reads the slot once the
|
||||
// endpoint finishes, proving the wiring still publishes it.
|
||||
var key = SeedKey(id: 99, name: "audit-actor-name");
|
||||
// The handler stashes the VERIFIED key's display name at
|
||||
// HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth+authz
|
||||
// succeeded, so AuditWriteMiddleware sees a populated Actor when it emits
|
||||
// the audit row. A capture middleware reads the slot once the endpoint
|
||||
// finishes, proving the wiring still publishes it.
|
||||
var method = SeedMethod(1, "stamp", "return 1;");
|
||||
|
||||
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) =>
|
||||
{
|
||||
@@ -225,9 +304,10 @@ public class EndpointExtensionsTests
|
||||
{
|
||||
services.AddSingleton(capture);
|
||||
});
|
||||
var token = await SeedKeyAsync(host, "key1", "audit-actor-name", new[] { "stamp" });
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var request = BuildPost("stamp", "{}");
|
||||
var request = BuildPost("stamp", "{}", token);
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -241,16 +321,16 @@ public class EndpointExtensionsTests
|
||||
// scadabridge.inbound_api.requests once, tagged with the resolved,
|
||||
// registered method name (method.Name) — the bounded identifier, not the
|
||||
// raw route value.
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
|
||||
"""[{"name":"value","type":"Integer","required":true}]""");
|
||||
|
||||
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 response = await client.SendAsync(BuildPost("echo", """{"value":7}"""));
|
||||
var response = await client.SendAsync(BuildPost("echo", """{"value":7}""", token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
// Filter by the method tag this test produced: the counter is a process-wide
|
||||
@@ -264,19 +344,21 @@ public class EndpointExtensionsTests
|
||||
[Fact]
|
||||
public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
|
||||
{
|
||||
// Telemetry follow-on: an auth/authz failure is still counted, but the
|
||||
// tag is a bounded sentinel ("<forbidden>") rather than the arbitrary
|
||||
// caller-supplied route value — so an attacker posting random method
|
||||
// names cannot blow up the `method` tag cardinality.
|
||||
var key = SeedKey();
|
||||
// Telemetry follow-on: an authz failure is still counted, but the tag is a
|
||||
// bounded sentinel ("<forbidden>") rather than the arbitrary caller-supplied
|
||||
// route value — so an attacker posting random method names cannot blow up
|
||||
// the `method` tag cardinality.
|
||||
var method = SeedMethod(1, "knownMethod", "return 1;");
|
||||
|
||||
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 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);
|
||||
var measurements = collector.Measurements;
|
||||
@@ -342,49 +424,100 @@ public class EndpointExtensionsTests
|
||||
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)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
request.Headers.Add("X-API-Key", ApiKeyValue);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static async Task<IHost> BuildHostAsync(
|
||||
ApiKey key,
|
||||
/// <summary>
|
||||
/// 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_<keyId>_<secret></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,
|
||||
Action<IApplicationBuilder>? customize = null,
|
||||
Action<IServiceCollection>? additionalServices = null)
|
||||
{
|
||||
var repo = Substitute.For<IInboundApiRepository>();
|
||||
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ApiKey> { key });
|
||||
repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
|
||||
.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()
|
||||
.ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings))
|
||||
.ConfigureWebHost(webBuilder =>
|
||||
{
|
||||
webBuilder
|
||||
.UseTestServer()
|
||||
.ConfigureServices(services =>
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
services.AddSingleton(Substitute.For<IInstanceLocator>());
|
||||
services.Configure<InboundApiOptions>(_ => { });
|
||||
services.AddInboundAPI();
|
||||
// Replace the production CommunicationService-backed
|
||||
// router and the configured HMAC hasher with test stubs
|
||||
// (same pattern as EndpointContentTypeTests).
|
||||
// Stand up the shared verifier + SQLite store + migration
|
||||
// hosted service against the per-test database and pepper,
|
||||
// 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.AddSingleton(Substitute.For<IInstanceRouter>());
|
||||
services.RemoveAll<IApiKeyHasher>();
|
||||
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
|
||||
services.AddLogging();
|
||||
additionalServices?.Invoke(services);
|
||||
})
|
||||
@@ -398,4 +531,26 @@ public class EndpointExtensionsTests
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user