ac96b83b08
Comm-016: delete dead HandleConnectionStateChanged + _debugSubscriptions / _inProgressDeployments tracking + ConnectionStateChanged message record. Disconnect detection is owned by the transport layers (gRPC keepalive PING ~25s; Ask-timeout at CommunicationService). Updates the Component-Communication.md design doc to make that explicit. SnF-018: NotificationForwarder.DeliverAsync now discards a corrupt buffered payload (Warning log + return true) instead of returning false and parking the row — honoring the design's "notifications do not park" invariant. DM-018: reconciliation no longer force-sets Enabled, preserving an intentional Disabled state after central failover. ESG-018: DeliverBufferedAsync (both ExternalSystemClient + DatabaseGateway) catches JsonException and returns false, turning a corrupt buffered row into a parked operation instead of a retry-forever poison message. InboundAPI-022: register ActiveNodeGate as IActiveNodeGate in the Central DI branch so standby-node gating is actually wired up in production. NS-019: remove orphaned NotificationDeliveryService / INotificationDeliveryService / NotificationResult; central notification delivery now lives entirely in NotificationOutbox. SEL-016: normalise From/To filters to UTC before ISO-string compare so non-UTC DateTimeOffset clients no longer get spuriously excluded events. TE-017: include Description on attributes/alarms and a HashableConnections projection (protocol, endpoint JSON, failover count) in the revision hash and DiffService; staleness detection now catches description-only and connection-endpoint edits. Transport-001 and Transport-002 (also High) remain Open — they're being handled in a follow-up batch because both touch BundleImporter.cs and must serialise.
205 lines
9.0 KiB
C#
205 lines
9.0 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using ScadaLink.Commons.Entities.InboundApi;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.InboundAPI;
|
|
|
|
namespace ScadaLink.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// WP-14: End-to-end integration tests for Phase 7 integration surfaces.
|
|
/// </summary>
|
|
public class IntegrationSurfaceTests
|
|
{
|
|
// ── Inbound API: auth + routing + parameter validation + error codes ──
|
|
|
|
[Fact]
|
|
public async Task InboundAPI_ApiKeyValidator_FullFlow_EndToEnd()
|
|
{
|
|
// Validates that ApiKeyValidator correctly chains all checks.
|
|
var repository = Substitute.For<IInboundApiRepository>();
|
|
var key = new ApiKey("test-key", "key-value-123") { Id = 1, IsEnabled = true };
|
|
var method = new ApiMethod("getStatus", "return 1;")
|
|
{
|
|
Id = 10,
|
|
ParameterDefinitions = "[{\"Name\":\"deviceId\",\"Type\":\"String\",\"Required\":true}]",
|
|
TimeoutSeconds = 30
|
|
};
|
|
|
|
// ConfigurationDatabase-012: the validator fetches every key and matches
|
|
// the candidate by HMAC hash in constant time (no secret-equality lookup).
|
|
repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
|
repository.GetMethodByNameAsync("getStatus").Returns(method);
|
|
repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
|
|
|
var validator = new ApiKeyValidator(repository);
|
|
|
|
// Valid key + approved method
|
|
var result = await validator.ValidateAsync("key-value-123", "getStatus");
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal(method, result.Method);
|
|
|
|
// Then validate parameters
|
|
using var doc = JsonDocument.Parse("{\"deviceId\": \"pump-01\"}");
|
|
var paramResult = ParameterValidator.Validate(doc.RootElement.Clone(), method.ParameterDefinitions);
|
|
Assert.True(paramResult.IsValid);
|
|
Assert.Equal("pump-01", paramResult.Parameters["deviceId"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void InboundAPI_ParameterValidation_ExtendedTypes()
|
|
{
|
|
// Validates the full extended type system: Boolean, Integer, Float, String, Object, List.
|
|
var definitions = JsonSerializer.Serialize(new[]
|
|
{
|
|
new { Name = "flag", Type = "Boolean", Required = true },
|
|
new { Name = "count", Type = "Integer", Required = true },
|
|
new { Name = "ratio", Type = "Float", Required = true },
|
|
new { Name = "name", Type = "String", Required = true },
|
|
new { Name = "config", Type = "Object", Required = true },
|
|
new { Name = "tags", Type = "List", Required = true }
|
|
});
|
|
|
|
var json = "{\"flag\":true,\"count\":42,\"ratio\":3.14,\"name\":\"test\",\"config\":{\"k\":\"v\"},\"tags\":[1,2]}";
|
|
using var doc = JsonDocument.Parse(json);
|
|
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
|
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal(true, result.Parameters["flag"]);
|
|
Assert.Equal((long)42, result.Parameters["count"]);
|
|
Assert.Equal(3.14, result.Parameters["ratio"]);
|
|
Assert.Equal("test", result.Parameters["name"]);
|
|
Assert.NotNull(result.Parameters["config"]);
|
|
Assert.NotNull(result.Parameters["tags"]);
|
|
}
|
|
|
|
// ── External System: error classification ──
|
|
|
|
[Fact]
|
|
public void ExternalSystem_ErrorClassification_TransientVsPermanent()
|
|
{
|
|
// WP-8: Verify the full classification spectrum
|
|
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.InternalServerError));
|
|
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.ServiceUnavailable));
|
|
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.RequestTimeout));
|
|
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient((HttpStatusCode)429));
|
|
|
|
Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.BadRequest));
|
|
Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.Unauthorized));
|
|
Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.Forbidden));
|
|
Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.NotFound));
|
|
}
|
|
|
|
// ── Notification: mock SMTP delivery ──
|
|
// NS-019: the site-shaped NotificationDeliveryService that this case exercised
|
|
// was removed when sites stopped delivering notifications. The central SMTP
|
|
// delivery path is now covered end-to-end by
|
|
// ScadaLink.NotificationOutbox.Tests.Delivery.EmailNotificationDeliveryAdapterTests;
|
|
// no equivalent integration-surface assertion is needed here.
|
|
|
|
// ── Script Context: integration API wiring ──
|
|
|
|
[Fact]
|
|
public async Task ScriptContext_ExternalSystem_Call_Wired()
|
|
{
|
|
// Verify that ExternalSystem.Call is accessible from ScriptRuntimeContext
|
|
var mockClient = Substitute.For<IExternalSystemClient>();
|
|
mockClient.CallAsync("api", "getData", null, Arg.Any<CancellationToken>())
|
|
.Returns(new ExternalCallResult(true, "{\"value\":1}", null));
|
|
|
|
var context = CreateMinimalScriptContext(externalSystemClient: mockClient);
|
|
|
|
var result = await context.ExternalSystem.Call("api", "getData");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal("{\"value\":1}", result.ResponseJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptContext_Notify_Send_Wired()
|
|
{
|
|
// Notification Outbox: Notify.Send enqueues into the site Store-and-Forward
|
|
// Engine and returns the NotificationId handle immediately.
|
|
var dbName = $"NotifyWired_{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr);
|
|
keepAlive.Open();
|
|
var storage = new StoreAndForward.StoreAndForwardStorage(
|
|
connStr, Microsoft.Extensions.Logging.Abstractions.NullLogger<StoreAndForward.StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
var saf = new StoreAndForward.StoreAndForwardService(
|
|
storage, new StoreAndForward.StoreAndForwardOptions(),
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<StoreAndForward.StoreAndForwardService>.Instance);
|
|
|
|
var context = CreateMinimalScriptContext(storeAndForward: saf);
|
|
|
|
var notificationId = await context.Notify.To("ops").Send("Alert", "Body");
|
|
|
|
Assert.False(string.IsNullOrEmpty(notificationId));
|
|
var buffered = await saf.GetMessageByIdAsync(notificationId);
|
|
Assert.NotNull(buffered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptContext_ExternalSystem_NoClient_Throws()
|
|
{
|
|
var context = CreateMinimalScriptContext();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => context.ExternalSystem.Call("api", "method"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptContext_Database_NoGateway_Throws()
|
|
{
|
|
var context = CreateMinimalScriptContext();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => context.Database.Connection("db"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptContext_Notify_NoService_Throws()
|
|
{
|
|
// No Store-and-Forward Engine wired → Notify.Send cannot enqueue and throws.
|
|
var context = CreateMinimalScriptContext();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => context.Notify.To("list").Send("subj", "body"));
|
|
}
|
|
|
|
private static SiteRuntime.Scripts.ScriptRuntimeContext CreateMinimalScriptContext(
|
|
IExternalSystemClient? externalSystemClient = null,
|
|
IDatabaseGateway? databaseGateway = null,
|
|
StoreAndForward.StoreAndForwardService? storeAndForward = null)
|
|
{
|
|
// Create a minimal context — we use Substitute.For<IActorRef> which is fine since
|
|
// we won't exercise Akka functionality in these tests.
|
|
var actorRef = Substitute.For<Akka.Actor.IActorRef>();
|
|
var compilationService = new SiteRuntime.Scripts.ScriptCompilationService(
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteRuntime.Scripts.ScriptCompilationService>.Instance);
|
|
var sharedLibrary = new SiteRuntime.Scripts.SharedScriptLibrary(
|
|
compilationService,
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteRuntime.Scripts.SharedScriptLibrary>.Instance);
|
|
|
|
return new SiteRuntime.Scripts.ScriptRuntimeContext(
|
|
actorRef,
|
|
actorRef,
|
|
sharedLibrary,
|
|
currentCallDepth: 0,
|
|
maxCallDepth: 10,
|
|
askTimeout: TimeSpan.FromSeconds(5),
|
|
instanceName: "test-instance",
|
|
logger: Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
|
|
externalSystemClient: externalSystemClient,
|
|
databaseGateway: databaseGateway,
|
|
storeAndForward: storeAndForward,
|
|
siteId: "test-site");
|
|
}
|
|
}
|