fix(management-service): resolve ManagementService-004,006,007,013 — PipeTo dispatch, JsonDocument disposal, unified serialization, endpoint tests; re-triage MS-009
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Akka.Actor;
|
||||
using Newtonsoft.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
@@ -42,6 +43,25 @@ public class ManagementActor : ReceiveActor
|
||||
Receive<ManagementEnvelope>(HandleEnvelope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializer settings for command results. <see cref="ReferenceHandler.IgnoreCycles"/>
|
||||
/// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
|
||||
/// camelCase matches what the CLI / HTTP layer expect (finding ManagementService-007).
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions ResultSerializerOptions = new()
|
||||
{
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a command result to JSON using <see cref="System.Text.Json"/> — the same
|
||||
/// serializer the HTTP endpoint uses — with cycle-safe settings.
|
||||
/// </summary>
|
||||
public static string SerializeResult(object? result) =>
|
||||
JsonSerializer.Serialize(result, ResultSerializerOptions);
|
||||
|
||||
private void HandleEnvelope(ManagementEnvelope envelope)
|
||||
{
|
||||
var sender = Sender;
|
||||
@@ -57,27 +77,42 @@ public class ManagementActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// Process command asynchronously with scoped DI
|
||||
Task.Run(async () =>
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
try
|
||||
{
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
|
||||
var json = JsonConvert.SerializeObject(result, Formatting.None);
|
||||
sender.Tell(new ManagementSuccess(correlationId, json));
|
||||
}
|
||||
catch (SiteScopeViolationException ex)
|
||||
{
|
||||
sender.Tell(new ManagementUnauthorized(correlationId, ex.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Management command {Command} failed (CorrelationId={CorrelationId})",
|
||||
envelope.Command.GetType().Name, correlationId);
|
||||
sender.Tell(new ManagementError(correlationId, ex.Message, "COMMAND_FAILED"));
|
||||
}
|
||||
});
|
||||
// Process the command and pipe the mapped result back to the captured sender.
|
||||
// PipeTo (rather than Task.Run + Tell-from-continuation) is the project's Akka.NET
|
||||
// convention: faults are mapped in the failure continuation, no actor state is
|
||||
// captured in the closure, and synchronous faults in command setup are still mapped.
|
||||
ProcessCommand(envelope, user)
|
||||
.PipeTo(sender,
|
||||
success: result => result,
|
||||
failure: ex => MapFault(ex, correlationId, envelope.Command));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a command on a scoped service provider and maps the result to a management
|
||||
/// response message. Returns a faulted task on error so the PipeTo failure
|
||||
/// continuation maps it uniformly.
|
||||
/// </summary>
|
||||
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
|
||||
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an exception from command processing to the appropriate management response.
|
||||
/// </summary>
|
||||
private object MapFault(Exception ex, string correlationId, object command)
|
||||
{
|
||||
// PipeTo wraps continuation exceptions; unwrap to find the real cause.
|
||||
var cause = ex is AggregateException agg ? agg.Flatten().InnerException ?? ex : ex;
|
||||
|
||||
if (cause is SiteScopeViolationException scope)
|
||||
return new ManagementUnauthorized(correlationId, scope.Message);
|
||||
|
||||
_logger.LogError(cause, "Management command {Command} failed (CorrelationId={CorrelationId})",
|
||||
command.GetType().Name, correlationId);
|
||||
return new ManagementError(correlationId, cause.Message, "COMMAND_FAILED");
|
||||
}
|
||||
|
||||
private static string? GetRequiredRole(object command) => command switch
|
||||
@@ -345,8 +380,23 @@ public class ManagementActor : ReceiveActor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to log an audit entry after a successful mutation.
|
||||
/// Logs an audit entry after a successful mutation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Audit-logging contract (finding ManagementService-009). Every mutating operation is
|
||||
/// audited exactly once, by whichever layer owns the write:
|
||||
/// <list type="bullet">
|
||||
/// <item>Handlers that mutate a repository directly (site, area, data-connection,
|
||||
/// external-system, notification, security, API-key, scope-rule) call this helper
|
||||
/// explicitly after the successful change.</item>
|
||||
/// <item>Handlers that delegate to a domain service (<c>TemplateService</c>,
|
||||
/// <c>SharedScriptService</c>, <c>InstanceService</c>, <c>AreaService</c>,
|
||||
/// <c>SiteService</c>, <c>TemplateFolderService</c>, <c>DeploymentService</c>,
|
||||
/// <c>ArtifactDeploymentService</c>) do NOT call this helper — those services own their
|
||||
/// own <see cref="IAuditService"/> dependency and audit internally. Adding an explicit
|
||||
/// <see cref="AuditAsync"/> call in such a handler would double-log.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
|
||||
{
|
||||
var auditService = sp.GetRequiredService<IAuditService>();
|
||||
|
||||
@@ -77,46 +77,19 @@ public static class ManagementEndpoints
|
||||
permittedSiteIds);
|
||||
|
||||
// 4. Parse command from request body
|
||||
JsonDocument doc;
|
||||
try
|
||||
string body;
|
||||
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
|
||||
{
|
||||
doc = await JsonDocument.ParseAsync(context.Request.Body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(new { error = "Invalid JSON body.", code = "BAD_REQUEST" }, statusCode: 400);
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
|
||||
var parse = ParseCommand(body);
|
||||
if (!parse.Success)
|
||||
{
|
||||
return Results.Json(new { error = "Missing 'command' field.", code = "BAD_REQUEST" }, statusCode: 400);
|
||||
return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400);
|
||||
}
|
||||
|
||||
var commandName = commandNameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(commandName))
|
||||
{
|
||||
return Results.Json(new { error = "Empty 'command' field.", code = "BAD_REQUEST" }, statusCode: 400);
|
||||
}
|
||||
|
||||
var commandType = ManagementCommandRegistry.Resolve(commandName);
|
||||
if (commandType == null)
|
||||
{
|
||||
return Results.Json(new { error = $"Unknown command: '{commandName}'.", code = "BAD_REQUEST" }, statusCode: 400);
|
||||
}
|
||||
|
||||
object command;
|
||||
try
|
||||
{
|
||||
var payloadElement = doc.RootElement.TryGetProperty("payload", out var p)
|
||||
? p
|
||||
: JsonDocument.Parse("{}").RootElement;
|
||||
command = JsonSerializer.Deserialize(payloadElement.GetRawText(), commandType,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Json(new { error = $"Failed to deserialize payload: {ex.Message}", code = "BAD_REQUEST" }, statusCode: 400);
|
||||
}
|
||||
var command = parse.Command!;
|
||||
|
||||
// 5. Dispatch to ManagementActor
|
||||
var holder = context.RequestServices.GetRequiredService<ManagementActorHolder>();
|
||||
@@ -148,4 +121,68 @@ public static class ManagementEndpoints
|
||||
_ => Results.Json(new { error = "Unexpected response.", code = "INTERNAL_ERROR" }, statusCode: 500)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a management request body into a strongly-typed command.
|
||||
/// </summary>
|
||||
public readonly record struct CommandParseResult(
|
||||
bool Success, object? Command, string? ErrorMessage, string? ErrorCode)
|
||||
{
|
||||
public static CommandParseResult Ok(object command) => new(true, command, null, null);
|
||||
public static CommandParseResult Fail(string message) => new(false, null, message, "BAD_REQUEST");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a management request body — a JSON object with a <c>command</c> name and an
|
||||
/// optional <c>payload</c> — into the strongly-typed command record. The parsed
|
||||
/// <see cref="JsonDocument"/> is disposed deterministically and the missing-payload
|
||||
/// case does not allocate a throwaway document (finding ManagementService-006).
|
||||
/// </summary>
|
||||
public static CommandParseResult ParseCommand(string body)
|
||||
{
|
||||
using JsonDocument doc = ParseDocument(body, out var parseError);
|
||||
if (parseError != null)
|
||||
return CommandParseResult.Fail(parseError);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
|
||||
return CommandParseResult.Fail("Missing 'command' field.");
|
||||
|
||||
var commandName = commandNameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(commandName))
|
||||
return CommandParseResult.Fail("Empty 'command' field.");
|
||||
|
||||
var commandType = ManagementCommandRegistry.Resolve(commandName);
|
||||
if (commandType == null)
|
||||
return CommandParseResult.Fail($"Unknown command: '{commandName}'.");
|
||||
|
||||
try
|
||||
{
|
||||
// Missing payload: deserialize from the empty-object literal rather than
|
||||
// allocating (and leaking) a throwaway JsonDocument.
|
||||
var payloadJson = doc.RootElement.TryGetProperty("payload", out var p)
|
||||
? p.GetRawText()
|
||||
: "{}";
|
||||
var command = JsonSerializer.Deserialize(payloadJson, commandType,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
return CommandParseResult.Ok(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CommandParseResult.Fail($"Failed to deserialize payload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument ParseDocument(string body, out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
error = null;
|
||||
return JsonDocument.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = "Invalid JSON body.";
|
||||
return JsonDocument.Parse("{}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user