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:
@@ -1,15 +1,20 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Types.InboundApi;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
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
|
||||
/// and any required parameters surfaced as a 400 even though the caller
|
||||
/// 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>
|
||||
public class EndpointContentTypeTests
|
||||
public sealed class EndpointContentTypeTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Stub hasher that returns its input unchanged. Lets the test pre-seed the
|
||||
/// repository with a known "hash" value without depending on the real
|
||||
/// HMAC-with-pepper hasher.
|
||||
/// </summary>
|
||||
private sealed class IdentityHasher : IApiKeyHasher
|
||||
{
|
||||
public string Hash(string keyValue) => keyValue;
|
||||
}
|
||||
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";
|
||||
|
||||
private readonly string _sqlitePath =
|
||||
Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite");
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
@@ -44,13 +53,8 @@ public class EndpointContentTypeTests
|
||||
[InlineData("APPLICATION/JSON")]
|
||||
public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType)
|
||||
{
|
||||
const string apiKeyValue = "test-key";
|
||||
const string methodName = "echoParam";
|
||||
|
||||
var key = ApiKey.FromHash("test", apiKeyValue);
|
||||
key.IsEnabled = true;
|
||||
key.Id = 1;
|
||||
|
||||
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
|
||||
{
|
||||
Id = 1,
|
||||
@@ -62,25 +66,22 @@ public class EndpointContentTypeTests
|
||||
};
|
||||
|
||||
var repo = Substitute.For<IInboundApiRepository>();
|
||||
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ApiKey> { key });
|
||||
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
|
||||
.Returns(method);
|
||||
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ApiKey> { key });
|
||||
|
||||
using var host = await BuildHostAsync(repo);
|
||||
var token = await SeedKeyAsync(host, methodName);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
{
|
||||
// Bypass HttpClient's MediaTypeHeaderValue auto-normalization by
|
||||
// setting the header through TryAddWithoutValidation — we need the
|
||||
// exact casing reach the server intact.
|
||||
// setting the header through MediaTypeHeaderValue.Parse — we need the
|
||||
// exact casing to reach the server intact.
|
||||
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}"))
|
||||
};
|
||||
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 body = await response.Content.ReadAsStringAsync();
|
||||
@@ -91,33 +92,56 @@ public class EndpointContentTypeTests
|
||||
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()
|
||||
.ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings))
|
||||
.ConfigureWebHost(webBuilder =>
|
||||
{
|
||||
webBuilder
|
||||
.UseTestServer()
|
||||
.ConfigureServices(services =>
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
// RouteHelper depends on IInstanceLocator + IInstanceRouter
|
||||
// (InboundAPI-017). Tests for content-type handling never
|
||||
// route, so both can be no-op stubs — the production
|
||||
// CommunicationServiceInstanceRouter would need a real
|
||||
// CommunicationService which isn't wired here.
|
||||
// route, so both can be no-op stubs.
|
||||
services.AddSingleton(Substitute.For<IInstanceLocator>());
|
||||
services.Configure<InboundApiOptions>(_ => { });
|
||||
services.AddInboundAPI();
|
||||
services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection);
|
||||
services.RemoveAll<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();
|
||||
})
|
||||
.Configure(app =>
|
||||
@@ -129,4 +153,24 @@ public class EndpointContentTypeTests
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user