Inbound API (WP-1–5):
- POST /api/{methodName} with X-API-Key auth (401/403)
- Parameter validation with extended type system (Object, List)
- Central script execution with configurable timeout
- Route.To() cross-site calls (Call, GetAttribute/SetAttribute batch)
- Failures-only logging
External System Gateway (WP-6–10):
- HTTP/REST client with JSON, API Key + Basic Auth
- Dual call modes: Call() synchronous, CachedCall() with S&F
- Error classification (transient: 5xx/408/429, permanent: 4xx)
- Database.Connection() (ADO.NET pooling) + Database.CachedWrite() (S&F)
Notification Service (WP-11–13):
- SMTP with OAuth2 Client Credentials + Basic Auth
- BCC delivery, plain text, token lifecycle
- Transient → S&F, permanent → returned to script
- ScriptRuntimeContext wired with ExternalSystem/Database/Notify APIs
Repository implementations: ExternalSystem, Notification, InboundApi, InstanceLocator
781 tests pass, zero warnings.
223 lines
9.2 KiB
C#
223 lines
9.2 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.Entities.Notifications;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.InboundAPI;
|
|
using ScadaLink.NotificationService;
|
|
|
|
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
|
|
};
|
|
|
|
repository.GetApiKeyByValueAsync("key-value-123").Returns(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 ──
|
|
|
|
[Fact]
|
|
public async Task Notification_Send_MockSmtp_Delivers()
|
|
{
|
|
var repository = Substitute.For<INotificationRepository>();
|
|
var smtpClient = Substitute.For<ISmtpClientWrapper>();
|
|
|
|
var list = new NotificationList("alerts") { Id = 1 };
|
|
var recipients = new List<NotificationRecipient>
|
|
{
|
|
new("Admin", "admin@example.com") { Id = 1, NotificationListId = 1 }
|
|
};
|
|
var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass"
|
|
};
|
|
|
|
repository.GetListByNameAsync("alerts").Returns(list);
|
|
repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
|
repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
|
|
|
|
var service = new NotificationDeliveryService(
|
|
repository,
|
|
() => smtpClient,
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
var result = await service.SendAsync("alerts", "Test Alert", "Something happened");
|
|
|
|
Assert.True(result.Success);
|
|
await smtpClient.Received(1).SendAsync(
|
|
"noreply@example.com",
|
|
Arg.Is<IEnumerable<string>>(r => r.Contains("admin@example.com")),
|
|
"Test Alert",
|
|
"Something happened",
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ── 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()
|
|
{
|
|
var mockNotify = Substitute.For<INotificationDeliveryService>();
|
|
mockNotify.SendAsync("ops", "Alert", "Body", Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
|
.Returns(new NotificationResult(true, null));
|
|
|
|
var context = CreateMinimalScriptContext(notificationService: mockNotify);
|
|
|
|
var result = await context.Notify.To("ops").Send("Alert", "Body");
|
|
|
|
Assert.True(result.Success);
|
|
}
|
|
|
|
[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()
|
|
{
|
|
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,
|
|
INotificationDeliveryService? notificationService = 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,
|
|
notificationService: notificationService);
|
|
}
|
|
}
|