Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs
T

576 lines
25 KiB
C#

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.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>
/// 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_&lt;keyId&gt;_&lt;secret&gt;</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 — 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 sealed class EndpointExtensionsTests : IDisposable
{
// 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";
// Review #1: the EXACT canonical 403 body shared by the "method not found" and
// "key not in scope" branches. Asserting against this literal (not just that the
// two bodies are equal-to-each-other) catches a single-branch divergence where
// BOTH branches change in lockstep but away from the agreed enumeration-safe
// body — the equal-to-each-other check alone would still pass in that case.
// Pinned to EndpointExtensions.NotApprovedMessage's JSON shape.
private const string NotApprovedBodyJson = """{"error":"API key not approved for this method"}""";
// 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
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> after the
/// inbound endpoint runs, so the actor-stash invariant can be asserted from
/// the test without running the real AuditWriteMiddleware.
/// </summary>
private sealed class AuditActorCapture
{
public string? CapturedActor { get; set; }
}
private static ApiMethod SeedMethod(
int id, string name, string script, string? paramDefs = null)
{
return new ApiMethod(name, script)
{
Id = id,
TimeoutSeconds = 10,
ParameterDefinitions = paramDefs,
};
}
[Fact]
public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson()
{
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
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}""", token);
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("7", body);
}
[Fact]
public async Task MissingBearer_Returns401()
{
var method = SeedMethod(1, "noKey", "return 1;");
using var host = await BuildHostAsync(method);
await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" });
var client = host.GetTestClient();
// No Authorization header — auth should reject with 401.
var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
};
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GarbageBearer_Returns401()
{
var method = SeedMethod(1, "noKey", "return 1;");
using var host = await BuildHostAsync(method);
await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" });
var client = host.GetTestClient();
// 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 body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
// Review #1: pin the not-in-scope body to the exact canonical literal so a
// single-branch divergence is caught here too (not only in the
// identical-bodies comparison test).
Assert.Equal(NotApprovedBodyJson, body);
}
[Fact]
public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope()
{
// 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(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();
// (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);
// Review #1: pin BOTH bodies to the exact canonical literal, so a future
// change that diverges either branch from the agreed body is caught even if
// the two branches change together (the equal-to-each-other check above
// would not catch that on its own).
Assert.Equal(NotApprovedBodyJson, unknownBody);
Assert.Equal(NotApprovedBodyJson, notInScopeBody);
}
[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();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Invalid JSON", body);
}
[Fact]
public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
{
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
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", "{}", token);
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
// ParameterValidator's error message is surfaced.
Assert.Contains("value", body);
}
[Fact]
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
{
// 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). 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(method);
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "boom" });
var client = host.GetTestClient();
var request = BuildPost("boom", "{}", token);
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
// Wiring contract: error body is JSON-shaped and the raw exception
// message is not leaked (the executor sanitises before this point).
Assert.Contains("error", body);
Assert.DoesNotContain("boom-msg", body);
}
[Fact]
public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems()
{
// 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(method, customize: builder =>
{
builder.Use(async (ctx, next) =>
{
await next();
if (ctx.Items.TryGetValue(
AuditWriteMiddleware.AuditActorItemKey, out var stashed)
&& stashed is string actorName)
{
capture.CapturedActor = actorName;
}
});
}, additionalServices: services =>
{
services.AddSingleton(capture);
});
var token = await SeedKeyAsync(host, "key1", "audit-actor-name", new[] { "stamp" });
var client = host.GetTestClient();
var request = BuildPost("stamp", "{}", token);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("audit-actor-name", capture.CapturedActor);
}
[Fact]
public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName()
{
// Telemetry follow-on: a successful inbound request increments
// scadabridge.inbound_api.requests once, tagged with the resolved,
// registered method name (method.Name) — the bounded identifier, not the
// raw route value.
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
using var collector = new InboundApiRequestCounterCollector();
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}""", token));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Filter by the method tag this test produced: the counter is a process-wide
// static, so a parallel test class could otherwise leak measurements in.
var echoTotal = collector.Measurements
.Where(m => m.Method == "echo")
.Sum(m => m.Value);
Assert.Equal(1, echoTotal);
}
[Fact]
public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
{
// 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(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", "{}", token));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var measurements = collector.Measurements;
// Cardinality safety: the arbitrary route value is never used as a tag.
Assert.DoesNotContain(measurements, m => m.Method == "totally-made-up-name");
// The failure path counts the request against the bounded sentinel.
Assert.Contains(measurements, m => m.Method == "<forbidden>" && m.Value == 1);
}
/// <summary>
/// Captures <c>scadabridge.inbound_api.requests</c> measurements (value + the
/// <c>method</c> tag) via a <see cref="MeterListener"/> for the duration of a test.
/// </summary>
private sealed class InboundApiRequestCounterCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly List<(long Value, string? Method)> _measurements = new();
private readonly object _gate = new();
public InboundApiRequestCounterCollector()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName
&& instrument.Name == "scadabridge.inbound_api.requests")
{
listener.EnableMeasurementEvents(instrument);
}
},
};
_listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
string? method = null;
foreach (var tag in tags)
{
if (tag.Key == "method")
{
method = tag.Value as string;
}
}
lock (_gate)
{
_measurements.Add((value, method));
}
});
_listener.Start();
}
public IReadOnlyList<(long Value, string? Method)> Measurements
{
get
{
lock (_gate)
{
return _measurements.ToList();
}
}
}
public void Dispose() => _listener.Dispose();
}
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.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
return request;
}
/// <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_&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,
Action<IApplicationBuilder>? customize = null,
Action<IServiceCollection>? additionalServices = null)
{
var repo = Substitute.For<IInboundApiRepository>();
repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
.Returns(method);
// 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((context, services) =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI();
// 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.AddLogging();
additionalServices?.Invoke(services);
})
.Configure(app =>
{
app.UseRouting();
customize?.Invoke(app);
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
});
});
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.
}
}
}