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; /// /// Auth re-arch (A+B): now /// authenticates with the shared ZB.MOM.WW.Auth.ApiKeys verifier — a Bearer /// token (Authorization: Bearer sbk_<keyId>_<secret>) replaces the /// raw X-API-Key header, and a key's Scopes 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 , 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 /// HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] (so the /// AuditWriteMiddleware sees a non-null Actor when it emits the audit row). /// 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"); /// /// Inline middleware that captures the value at /// HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] after the /// inbound endpoint runs, so the actor-stash invariant can be asserted from /// the test without running the real AuditWriteMiddleware. /// 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 ("") 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 == "" && m.Value == 1); } /// /// Captures scadabridge.inbound_api.requests measurements (value + the /// method tag) via a for the duration of a test. /// 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((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; } /// /// Seeds a key with the given scopes into the library SQLite store via /// (the public admin seam) and /// returns the assembled Bearer token (sbk_<keyId>_<secret>) — /// 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. /// private static async Task SeedKeyAsync( IHost host, string keyId, string displayName, IReadOnlyCollection scopes) { var commands = BuildAdminCommands(host); var result = await commands.CreateKeyAsync( keyId, displayName, new HashSet(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); } /// /// Builds an 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. /// private static ApiKeyAdminCommands BuildAdminCommands(IHost host) { var services = host.Services; return new ApiKeyAdminCommands( services.GetRequiredService>().Value, services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); } private async Task BuildHostAsync( ApiMethod method, Action? customize = null, Action? additionalServices = null) { var repo = Substitute.For(); repo.GetMethodByNameAsync(method.Name, Arg.Any()) .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 { [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()); services.Configure(_ => { }); 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(); services.AddSingleton(Substitute.For()); 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. } } }