feat(transport): bundle entity DTOs + secret carving in EntitySerializer
This commit is contained in:
155
src/ScadaLink.Transport/Serialization/EntityDtos.cs
Normal file
155
src/ScadaLink.Transport/Serialization/EntityDtos.cs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Entities.Scripts;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Transport.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory aggregate of all bundle-eligible Commons entities. Matches the
|
||||||
|
/// shape of <see cref="BundleContentDto"/> but uses the real persistence-
|
||||||
|
/// ignorant POCO types — what <see cref="EntitySerializer"/> consumes/produces
|
||||||
|
/// on the application side of the bundle boundary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EntityAggregate(
|
||||||
|
IReadOnlyList<TemplateFolder> TemplateFolders,
|
||||||
|
IReadOnlyList<Template> Templates,
|
||||||
|
IReadOnlyList<SharedScript> SharedScripts,
|
||||||
|
IReadOnlyList<ExternalSystemDefinition> ExternalSystems,
|
||||||
|
IReadOnlyList<ExternalSystemMethod> ExternalSystemMethods,
|
||||||
|
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
|
||||||
|
IReadOnlyList<NotificationList> NotificationLists,
|
||||||
|
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
|
||||||
|
IReadOnlyList<ApiKey> ApiKeys,
|
||||||
|
IReadOnlyList<ApiMethod> ApiMethods);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level serializable bundle payload. Lists are sequenced in dependency
|
||||||
|
/// order so importers can apply them inline. Lists are never null on the wire
|
||||||
|
/// — empty arrays are preferred over nulls so JSON consumers can rely on each
|
||||||
|
/// property being present.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BundleContentDto(
|
||||||
|
IReadOnlyList<TemplateFolderDto> TemplateFolders,
|
||||||
|
IReadOnlyList<TemplateDto> Templates,
|
||||||
|
IReadOnlyList<SharedScriptDto> SharedScripts,
|
||||||
|
IReadOnlyList<ExternalSystemDto> ExternalSystems,
|
||||||
|
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
|
||||||
|
IReadOnlyList<NotificationListDto> NotificationLists,
|
||||||
|
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
|
||||||
|
IReadOnlyList<ApiKeyDto> ApiKeys,
|
||||||
|
IReadOnlyList<ApiMethodDto> ApiMethods);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Carved-off secret values for an entity. The outer DTO carries all non-
|
||||||
|
/// sensitive fields; secrets land here so a future "share without secrets"
|
||||||
|
/// export mode can drop this block without touching anything else.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SecretsBlock(IReadOnlyDictionary<string, string> Values);
|
||||||
|
|
||||||
|
public sealed record TemplateFolderDto(
|
||||||
|
string Name,
|
||||||
|
string? ParentName,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public sealed record TemplateDto(
|
||||||
|
string Name,
|
||||||
|
string? FolderName,
|
||||||
|
string? BaseTemplateName,
|
||||||
|
string? Description,
|
||||||
|
IReadOnlyList<TemplateAttributeDto> Attributes,
|
||||||
|
IReadOnlyList<TemplateAlarmDto> Alarms,
|
||||||
|
IReadOnlyList<TemplateScriptDto> Scripts,
|
||||||
|
IReadOnlyList<TemplateCompositionDto> Compositions);
|
||||||
|
|
||||||
|
public sealed record TemplateAttributeDto(
|
||||||
|
string Name,
|
||||||
|
string? Value,
|
||||||
|
DataType DataType,
|
||||||
|
bool IsLocked,
|
||||||
|
string? Description,
|
||||||
|
string? DataSourceReference);
|
||||||
|
|
||||||
|
public sealed record TemplateAlarmDto(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
int PriorityLevel,
|
||||||
|
AlarmTriggerType TriggerType,
|
||||||
|
string? TriggerConfiguration,
|
||||||
|
bool IsLocked);
|
||||||
|
|
||||||
|
public sealed record TemplateScriptDto(
|
||||||
|
string Name,
|
||||||
|
string Code,
|
||||||
|
string? TriggerType,
|
||||||
|
string? TriggerConfiguration,
|
||||||
|
string? ParameterDefinitions,
|
||||||
|
string? ReturnDefinition,
|
||||||
|
bool IsLocked);
|
||||||
|
|
||||||
|
public sealed record TemplateCompositionDto(
|
||||||
|
string InstanceName,
|
||||||
|
string ComposedTemplateName);
|
||||||
|
|
||||||
|
public sealed record SharedScriptDto(
|
||||||
|
string Name,
|
||||||
|
string Code,
|
||||||
|
string? ParameterDefinitions,
|
||||||
|
string? ReturnDefinition);
|
||||||
|
|
||||||
|
public sealed record ExternalSystemDto(
|
||||||
|
string Name,
|
||||||
|
string BaseUrl,
|
||||||
|
string AuthType,
|
||||||
|
IReadOnlyList<ExternalSystemMethodDto> Methods,
|
||||||
|
SecretsBlock? Secrets);
|
||||||
|
|
||||||
|
public sealed record ExternalSystemMethodDto(
|
||||||
|
string Name,
|
||||||
|
string HttpMethod,
|
||||||
|
string Path,
|
||||||
|
string? ParameterDefinitions,
|
||||||
|
string? ReturnDefinition);
|
||||||
|
|
||||||
|
public sealed record DatabaseConnectionDto(
|
||||||
|
string Name,
|
||||||
|
int MaxRetries,
|
||||||
|
TimeSpan RetryDelay,
|
||||||
|
SecretsBlock? Secrets);
|
||||||
|
|
||||||
|
public sealed record NotificationListDto(
|
||||||
|
string Name,
|
||||||
|
NotificationType Type,
|
||||||
|
IReadOnlyList<NotificationRecipientDto> Recipients);
|
||||||
|
|
||||||
|
public sealed record NotificationRecipientDto(
|
||||||
|
string Name,
|
||||||
|
string EmailAddress);
|
||||||
|
|
||||||
|
public sealed record SmtpConfigDto(
|
||||||
|
string Host,
|
||||||
|
int Port,
|
||||||
|
string AuthType,
|
||||||
|
string FromAddress,
|
||||||
|
string? TlsMode,
|
||||||
|
int ConnectionTimeoutSeconds,
|
||||||
|
int MaxConcurrentConnections,
|
||||||
|
int MaxRetries,
|
||||||
|
TimeSpan RetryDelay,
|
||||||
|
SecretsBlock? Secrets);
|
||||||
|
|
||||||
|
public sealed record ApiKeyDto(
|
||||||
|
string Name,
|
||||||
|
string KeyHash,
|
||||||
|
bool IsEnabled,
|
||||||
|
SecretsBlock? Secrets);
|
||||||
|
|
||||||
|
public sealed record ApiMethodDto(
|
||||||
|
string Name,
|
||||||
|
string Script,
|
||||||
|
string? ApprovedApiKeyIds,
|
||||||
|
string? ParameterDefinitions,
|
||||||
|
string? ReturnDefinition,
|
||||||
|
int TimeoutSeconds);
|
||||||
344
src/ScadaLink.Transport/Serialization/EntitySerializer.cs
Normal file
344
src/ScadaLink.Transport/Serialization/EntitySerializer.cs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Entities.Scripts;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
|
||||||
|
namespace ScadaLink.Transport.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts between the persistence-shaped <see cref="EntityAggregate"/> and
|
||||||
|
/// the wire-shaped <see cref="BundleContentDto"/>. The conversion strips EF
|
||||||
|
/// identity (FK ints) and references inter-entity links by name so bundles
|
||||||
|
/// remain portable across environments. Secrets are carved into a per-entity
|
||||||
|
/// <see cref="SecretsBlock"/> so the bundle's secret-free public surface can
|
||||||
|
/// be inspected (or, in future, exported without secrets) without touching the
|
||||||
|
/// rest of the payload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EntitySerializer
|
||||||
|
{
|
||||||
|
public BundleContentDto ToBundleContent(EntityAggregate aggregate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(aggregate);
|
||||||
|
|
||||||
|
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
|
||||||
|
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
|
||||||
|
|
||||||
|
return new BundleContentDto(
|
||||||
|
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
|
||||||
|
Name: f.Name,
|
||||||
|
ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null,
|
||||||
|
SortOrder: f.SortOrder)).ToList(),
|
||||||
|
Templates: aggregate.Templates.Select(t => new TemplateDto(
|
||||||
|
Name: t.Name,
|
||||||
|
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
|
||||||
|
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
|
||||||
|
Description: t.Description,
|
||||||
|
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
|
||||||
|
Name: a.Name,
|
||||||
|
Value: a.Value,
|
||||||
|
DataType: a.DataType,
|
||||||
|
IsLocked: a.IsLocked,
|
||||||
|
Description: a.Description,
|
||||||
|
DataSourceReference: a.DataSourceReference)).ToList(),
|
||||||
|
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
|
||||||
|
Name: a.Name,
|
||||||
|
Description: a.Description,
|
||||||
|
PriorityLevel: a.PriorityLevel,
|
||||||
|
TriggerType: a.TriggerType,
|
||||||
|
TriggerConfiguration: a.TriggerConfiguration,
|
||||||
|
IsLocked: a.IsLocked)).ToList(),
|
||||||
|
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
|
||||||
|
Name: s.Name,
|
||||||
|
Code: s.Code,
|
||||||
|
TriggerType: s.TriggerType,
|
||||||
|
TriggerConfiguration: s.TriggerConfiguration,
|
||||||
|
ParameterDefinitions: s.ParameterDefinitions,
|
||||||
|
ReturnDefinition: s.ReturnDefinition,
|
||||||
|
IsLocked: s.IsLocked)).ToList(),
|
||||||
|
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
||||||
|
InstanceName: c.InstanceName,
|
||||||
|
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())).ToList(),
|
||||||
|
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
|
||||||
|
Name: s.Name,
|
||||||
|
Code: s.Code,
|
||||||
|
ParameterDefinitions: s.ParameterDefinitions,
|
||||||
|
ReturnDefinition: s.ReturnDefinition)).ToList(),
|
||||||
|
ExternalSystems: aggregate.ExternalSystems.Select(sys =>
|
||||||
|
{
|
||||||
|
var methods = aggregate.ExternalSystemMethods
|
||||||
|
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
|
||||||
|
.Select(m => new ExternalSystemMethodDto(
|
||||||
|
Name: m.Name,
|
||||||
|
HttpMethod: m.HttpMethod,
|
||||||
|
Path: m.Path,
|
||||||
|
ParameterDefinitions: m.ParameterDefinitions,
|
||||||
|
ReturnDefinition: m.ReturnDefinition))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
SecretsBlock? secrets = null;
|
||||||
|
if (!string.IsNullOrEmpty(sys.AuthConfiguration))
|
||||||
|
{
|
||||||
|
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["AuthConfiguration"] = sys.AuthConfiguration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExternalSystemDto(
|
||||||
|
Name: sys.Name,
|
||||||
|
BaseUrl: sys.EndpointUrl,
|
||||||
|
AuthType: sys.AuthType,
|
||||||
|
Methods: methods,
|
||||||
|
Secrets: secrets);
|
||||||
|
}).ToList(),
|
||||||
|
DatabaseConnections: aggregate.DatabaseConnections.Select(dc => new DatabaseConnectionDto(
|
||||||
|
Name: dc.Name,
|
||||||
|
MaxRetries: dc.MaxRetries,
|
||||||
|
RetryDelay: dc.RetryDelay,
|
||||||
|
// Connection strings typically embed credentials inline; treat the entire
|
||||||
|
// string as secret rather than try to parse out the password fragment.
|
||||||
|
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["ConnectionString"] = dc.ConnectionString,
|
||||||
|
}))).ToList(),
|
||||||
|
NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
|
||||||
|
Name: nl.Name,
|
||||||
|
Type: nl.Type,
|
||||||
|
Recipients: nl.Recipients.Select(r => new NotificationRecipientDto(
|
||||||
|
Name: r.Name,
|
||||||
|
EmailAddress: r.EmailAddress)).ToList())).ToList(),
|
||||||
|
SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp =>
|
||||||
|
{
|
||||||
|
SecretsBlock? secrets = null;
|
||||||
|
if (!string.IsNullOrEmpty(smtp.Credentials))
|
||||||
|
{
|
||||||
|
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["Credentials"] = smtp.Credentials,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SmtpConfigDto(
|
||||||
|
Host: smtp.Host,
|
||||||
|
Port: smtp.Port,
|
||||||
|
AuthType: smtp.AuthType,
|
||||||
|
FromAddress: smtp.FromAddress,
|
||||||
|
TlsMode: smtp.TlsMode,
|
||||||
|
ConnectionTimeoutSeconds: smtp.ConnectionTimeoutSeconds,
|
||||||
|
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
|
||||||
|
MaxRetries: smtp.MaxRetries,
|
||||||
|
RetryDelay: smtp.RetryDelay,
|
||||||
|
Secrets: secrets);
|
||||||
|
}).ToList(),
|
||||||
|
// ApiKey stores only KeyHash already; no plaintext to carve. SecretsBlock
|
||||||
|
// stays null per design — KeyHash is on the public DTO.
|
||||||
|
ApiKeys: aggregate.ApiKeys.Select(k => new ApiKeyDto(
|
||||||
|
Name: k.Name,
|
||||||
|
KeyHash: k.KeyHash,
|
||||||
|
IsEnabled: k.IsEnabled,
|
||||||
|
Secrets: null)).ToList(),
|
||||||
|
ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
|
||||||
|
Name: m.Name,
|
||||||
|
Script: m.Script,
|
||||||
|
ApprovedApiKeyIds: m.ApprovedApiKeyIds,
|
||||||
|
ParameterDefinitions: m.ParameterDefinitions,
|
||||||
|
ReturnDefinition: m.ReturnDefinition,
|
||||||
|
TimeoutSeconds: m.TimeoutSeconds)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public EntityAggregate FromBundleContent(BundleContentDto content)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
|
||||||
|
// Folders: assign synthetic ids by ordinal, resolve parent pointers by name.
|
||||||
|
var folders = content.TemplateFolders
|
||||||
|
.Select((dto, ix) => new TemplateFolder(dto.Name) { Id = ix + 1, SortOrder = dto.SortOrder })
|
||||||
|
.ToList();
|
||||||
|
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
|
||||||
|
for (var i = 0; i < folders.Count; i++)
|
||||||
|
{
|
||||||
|
var parentName = content.TemplateFolders[i].ParentName;
|
||||||
|
if (parentName is not null && folderIdByName.TryGetValue(parentName, out var parentId))
|
||||||
|
{
|
||||||
|
folders[i].ParentFolderId = parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates: same pattern — assign ids, second pass resolves base + folder.
|
||||||
|
var templates = content.Templates
|
||||||
|
.Select((dto, ix) =>
|
||||||
|
{
|
||||||
|
var t = new Template(dto.Name) { Id = ix + 1, Description = dto.Description };
|
||||||
|
foreach (var a in dto.Attributes)
|
||||||
|
{
|
||||||
|
t.Attributes.Add(new TemplateAttribute(a.Name)
|
||||||
|
{
|
||||||
|
TemplateId = t.Id,
|
||||||
|
Value = a.Value,
|
||||||
|
DataType = a.DataType,
|
||||||
|
IsLocked = a.IsLocked,
|
||||||
|
Description = a.Description,
|
||||||
|
DataSourceReference = a.DataSourceReference,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var al in dto.Alarms)
|
||||||
|
{
|
||||||
|
t.Alarms.Add(new TemplateAlarm(al.Name)
|
||||||
|
{
|
||||||
|
TemplateId = t.Id,
|
||||||
|
Description = al.Description,
|
||||||
|
PriorityLevel = al.PriorityLevel,
|
||||||
|
TriggerType = al.TriggerType,
|
||||||
|
TriggerConfiguration = al.TriggerConfiguration,
|
||||||
|
IsLocked = al.IsLocked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var s in dto.Scripts)
|
||||||
|
{
|
||||||
|
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
|
||||||
|
{
|
||||||
|
TemplateId = t.Id,
|
||||||
|
TriggerType = s.TriggerType,
|
||||||
|
TriggerConfiguration = s.TriggerConfiguration,
|
||||||
|
ParameterDefinitions = s.ParameterDefinitions,
|
||||||
|
ReturnDefinition = s.ReturnDefinition,
|
||||||
|
IsLocked = s.IsLocked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var templateIdByName = templates.ToDictionary(t => t.Name, t => t.Id, StringComparer.Ordinal);
|
||||||
|
for (var i = 0; i < templates.Count; i++)
|
||||||
|
{
|
||||||
|
var dto = content.Templates[i];
|
||||||
|
if (dto.FolderName is not null && folderIdByName.TryGetValue(dto.FolderName, out var fid))
|
||||||
|
{
|
||||||
|
templates[i].FolderId = fid;
|
||||||
|
}
|
||||||
|
if (dto.BaseTemplateName is not null && templateIdByName.TryGetValue(dto.BaseTemplateName, out var btid))
|
||||||
|
{
|
||||||
|
templates[i].ParentTemplateId = btid;
|
||||||
|
}
|
||||||
|
foreach (var compDto in dto.Compositions)
|
||||||
|
{
|
||||||
|
var comp = new TemplateComposition(compDto.InstanceName)
|
||||||
|
{
|
||||||
|
TemplateId = templates[i].Id,
|
||||||
|
};
|
||||||
|
if (templateIdByName.TryGetValue(compDto.ComposedTemplateName, out var ctid))
|
||||||
|
{
|
||||||
|
comp.ComposedTemplateId = ctid;
|
||||||
|
}
|
||||||
|
templates[i].Compositions.Add(comp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedScripts = content.SharedScripts
|
||||||
|
.Select((dto, ix) => new SharedScript(dto.Name, dto.Code)
|
||||||
|
{
|
||||||
|
Id = ix + 1,
|
||||||
|
ParameterDefinitions = dto.ParameterDefinitions,
|
||||||
|
ReturnDefinition = dto.ReturnDefinition,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var externalSystems = new List<ExternalSystemDefinition>();
|
||||||
|
var externalSystemMethods = new List<ExternalSystemMethod>();
|
||||||
|
for (var ix = 0; ix < content.ExternalSystems.Count; ix++)
|
||||||
|
{
|
||||||
|
var dto = content.ExternalSystems[ix];
|
||||||
|
var sys = new ExternalSystemDefinition(dto.Name, dto.BaseUrl, dto.AuthType)
|
||||||
|
{
|
||||||
|
Id = ix + 1,
|
||||||
|
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
|
||||||
|
};
|
||||||
|
externalSystems.Add(sys);
|
||||||
|
foreach (var m in dto.Methods)
|
||||||
|
{
|
||||||
|
externalSystemMethods.Add(new ExternalSystemMethod(m.Name, m.HttpMethod, m.Path)
|
||||||
|
{
|
||||||
|
ExternalSystemDefinitionId = sys.Id,
|
||||||
|
ParameterDefinitions = m.ParameterDefinitions,
|
||||||
|
ReturnDefinition = m.ReturnDefinition,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var databaseConnections = content.DatabaseConnections
|
||||||
|
.Select((dto, ix) =>
|
||||||
|
{
|
||||||
|
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
|
||||||
|
return new DatabaseConnectionDefinition(dto.Name, connStr)
|
||||||
|
{
|
||||||
|
Id = ix + 1,
|
||||||
|
MaxRetries = dto.MaxRetries,
|
||||||
|
RetryDelay = dto.RetryDelay,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var notificationLists = content.NotificationLists
|
||||||
|
.Select((dto, ix) =>
|
||||||
|
{
|
||||||
|
var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
|
||||||
|
foreach (var r in dto.Recipients)
|
||||||
|
{
|
||||||
|
list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)
|
||||||
|
{
|
||||||
|
NotificationListId = list.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var smtpConfigurations = content.SmtpConfigs
|
||||||
|
.Select((dto, ix) => new SmtpConfiguration(dto.Host, dto.AuthType, dto.FromAddress)
|
||||||
|
{
|
||||||
|
Id = ix + 1,
|
||||||
|
Port = dto.Port,
|
||||||
|
Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null,
|
||||||
|
TlsMode = dto.TlsMode,
|
||||||
|
ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
|
||||||
|
MaxConcurrentConnections = dto.MaxConcurrentConnections,
|
||||||
|
MaxRetries = dto.MaxRetries,
|
||||||
|
RetryDelay = dto.RetryDelay,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var apiKeys = content.ApiKeys
|
||||||
|
.Select((dto, ix) =>
|
||||||
|
{
|
||||||
|
var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
|
||||||
|
key.Id = ix + 1;
|
||||||
|
key.IsEnabled = dto.IsEnabled;
|
||||||
|
return key;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var apiMethods = content.ApiMethods
|
||||||
|
.Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
|
||||||
|
{
|
||||||
|
Id = ix + 1,
|
||||||
|
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
|
||||||
|
ParameterDefinitions = dto.ParameterDefinitions,
|
||||||
|
ReturnDefinition = dto.ReturnDefinition,
|
||||||
|
TimeoutSeconds = dto.TimeoutSeconds,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new EntityAggregate(
|
||||||
|
TemplateFolders: folders,
|
||||||
|
Templates: templates,
|
||||||
|
SharedScripts: sharedScripts,
|
||||||
|
ExternalSystems: externalSystems,
|
||||||
|
ExternalSystemMethods: externalSystemMethods,
|
||||||
|
DatabaseConnections: databaseConnections,
|
||||||
|
NotificationLists: notificationLists,
|
||||||
|
SmtpConfigurations: smtpConfigurations,
|
||||||
|
ApiKeys: apiKeys,
|
||||||
|
ApiMethods: apiMethods);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Entities.Scripts;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Transport.Serialization;
|
||||||
|
|
||||||
|
namespace ScadaLink.Transport.Tests.Serialization;
|
||||||
|
|
||||||
|
public sealed class EntitySerializerTests
|
||||||
|
{
|
||||||
|
private static EntityAggregate MakeEmptyAggregate() => new(
|
||||||
|
TemplateFolders: Array.Empty<TemplateFolder>(),
|
||||||
|
Templates: Array.Empty<Template>(),
|
||||||
|
SharedScripts: Array.Empty<SharedScript>(),
|
||||||
|
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
|
||||||
|
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
|
||||||
|
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
|
||||||
|
NotificationLists: Array.Empty<NotificationList>(),
|
||||||
|
SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
|
||||||
|
ApiKeys: Array.Empty<ApiKey>(),
|
||||||
|
ApiMethods: Array.Empty<ApiMethod>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDto_carves_external_system_credentials_into_secrets_block()
|
||||||
|
{
|
||||||
|
var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = "{\"apiKey\":\"super-secret\"}",
|
||||||
|
};
|
||||||
|
var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
|
||||||
|
|
||||||
|
var sut = new EntitySerializer();
|
||||||
|
var dto = sut.ToBundleContent(aggregate);
|
||||||
|
|
||||||
|
var dtoSys = Assert.Single(dto.ExternalSystems);
|
||||||
|
Assert.NotNull(dtoSys.Secrets);
|
||||||
|
Assert.True(dtoSys.Secrets!.Values.ContainsKey("AuthConfiguration"));
|
||||||
|
Assert.Equal("{\"apiKey\":\"super-secret\"}", dtoSys.Secrets.Values["AuthConfiguration"]);
|
||||||
|
// Public part does not carry the secret.
|
||||||
|
Assert.Equal("erp", dtoSys.Name);
|
||||||
|
Assert.Equal("https://erp/api", dtoSys.BaseUrl);
|
||||||
|
Assert.Equal("ApiKey", dtoSys.AuthType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDto_carves_smtp_password_into_secrets_block()
|
||||||
|
{
|
||||||
|
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 587,
|
||||||
|
Credentials = "user:p@ssw0rd",
|
||||||
|
};
|
||||||
|
var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
|
||||||
|
|
||||||
|
var dto = new EntitySerializer().ToBundleContent(aggregate);
|
||||||
|
|
||||||
|
var dtoSmtp = Assert.Single(dto.SmtpConfigs);
|
||||||
|
Assert.NotNull(dtoSmtp.Secrets);
|
||||||
|
Assert.Equal("user:p@ssw0rd", dtoSmtp.Secrets!.Values["Credentials"]);
|
||||||
|
Assert.Equal("smtp.example.com", dtoSmtp.Host);
|
||||||
|
Assert.Equal(587, dtoSmtp.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_template_preserves_attributes_alarms_scripts_composition()
|
||||||
|
{
|
||||||
|
var folder = new TemplateFolder("root") { Id = 1, SortOrder = 0 };
|
||||||
|
var basic = new Template("Basic") { Id = 1, FolderId = 1, Description = "base" };
|
||||||
|
basic.Attributes.Add(new TemplateAttribute("Pressure")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TemplateId = 1,
|
||||||
|
DataType = DataType.Double,
|
||||||
|
Value = "0",
|
||||||
|
IsLocked = true,
|
||||||
|
Description = "PSI",
|
||||||
|
});
|
||||||
|
basic.Alarms.Add(new TemplateAlarm("High")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TemplateId = 1,
|
||||||
|
PriorityLevel = 2,
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"threshold\":100}",
|
||||||
|
IsLocked = false,
|
||||||
|
});
|
||||||
|
basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TemplateId = 1,
|
||||||
|
TriggerType = "Periodic",
|
||||||
|
ParameterDefinitions = "[]",
|
||||||
|
ReturnDefinition = "void",
|
||||||
|
IsLocked = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
var assembly = new Template("Assembly") { Id = 2, FolderId = 1 };
|
||||||
|
assembly.Compositions.Add(new TemplateComposition("MotorA")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TemplateId = 2,
|
||||||
|
ComposedTemplateId = 1, // refers to "Basic".
|
||||||
|
});
|
||||||
|
|
||||||
|
var aggregate = MakeEmptyAggregate() with
|
||||||
|
{
|
||||||
|
TemplateFolders = new[] { folder },
|
||||||
|
Templates = new[] { basic, assembly },
|
||||||
|
};
|
||||||
|
|
||||||
|
var sut = new EntitySerializer();
|
||||||
|
var dto = sut.ToBundleContent(aggregate);
|
||||||
|
var roundTripped = sut.FromBundleContent(dto);
|
||||||
|
|
||||||
|
var rtBasic = Assert.Single(roundTripped.Templates, t => t.Name == "Basic");
|
||||||
|
var rtAttr = Assert.Single(rtBasic.Attributes);
|
||||||
|
Assert.Equal("Pressure", rtAttr.Name);
|
||||||
|
Assert.Equal(DataType.Double, rtAttr.DataType);
|
||||||
|
Assert.Equal("0", rtAttr.Value);
|
||||||
|
Assert.True(rtAttr.IsLocked);
|
||||||
|
|
||||||
|
var rtAlarm = Assert.Single(rtBasic.Alarms);
|
||||||
|
Assert.Equal("High", rtAlarm.Name);
|
||||||
|
Assert.Equal(AlarmTriggerType.RangeViolation, rtAlarm.TriggerType);
|
||||||
|
Assert.Equal("{\"threshold\":100}", rtAlarm.TriggerConfiguration);
|
||||||
|
Assert.Equal(2, rtAlarm.PriorityLevel);
|
||||||
|
|
||||||
|
var rtScript = Assert.Single(rtBasic.Scripts);
|
||||||
|
Assert.Equal("OnUpdate", rtScript.Name);
|
||||||
|
Assert.Equal("return 1;", rtScript.Code);
|
||||||
|
Assert.Equal("Periodic", rtScript.TriggerType);
|
||||||
|
|
||||||
|
var rtAssembly = Assert.Single(roundTripped.Templates, t => t.Name == "Assembly");
|
||||||
|
var rtComp = Assert.Single(rtAssembly.Compositions);
|
||||||
|
Assert.Equal("MotorA", rtComp.InstanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_template_folder_preserves_hierarchy()
|
||||||
|
{
|
||||||
|
var root = new TemplateFolder("Root") { Id = 1, SortOrder = 0 };
|
||||||
|
var child = new TemplateFolder("Pumps") { Id = 2, ParentFolderId = 1, SortOrder = 1 };
|
||||||
|
|
||||||
|
var aggregate = MakeEmptyAggregate() with
|
||||||
|
{
|
||||||
|
TemplateFolders = new[] { root, child },
|
||||||
|
};
|
||||||
|
|
||||||
|
var sut = new EntitySerializer();
|
||||||
|
var dto = sut.ToBundleContent(aggregate);
|
||||||
|
var rt = sut.FromBundleContent(dto);
|
||||||
|
|
||||||
|
Assert.Equal(2, rt.TemplateFolders.Count);
|
||||||
|
var rtRoot = Assert.Single(rt.TemplateFolders, f => f.Name == "Root");
|
||||||
|
var rtChild = Assert.Single(rt.TemplateFolders, f => f.Name == "Pumps");
|
||||||
|
Assert.Null(rtRoot.ParentFolderId);
|
||||||
|
// Hierarchy is preserved by name reference; new local ids get assigned but
|
||||||
|
// the child's parent must still point at the row whose name is "Root".
|
||||||
|
Assert.NotNull(rtChild.ParentFolderId);
|
||||||
|
Assert.Equal(rtRoot.Id, rtChild.ParentFolderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets()
|
||||||
|
{
|
||||||
|
var dto = new BundleContentDto(
|
||||||
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||||
|
Templates: Array.Empty<TemplateDto>(),
|
||||||
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||||
|
ExternalSystems: new[]
|
||||||
|
{
|
||||||
|
new ExternalSystemDto("erp", "https://x", "None", Array.Empty<ExternalSystemMethodDto>(), Secrets: null),
|
||||||
|
},
|
||||||
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||||
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||||
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||||
|
ApiKeys: Array.Empty<ApiKeyDto>(),
|
||||||
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||||
|
|
||||||
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||||
|
|
||||||
|
var sys = Assert.Single(aggregate.ExternalSystems);
|
||||||
|
Assert.Null(sys.AuthConfiguration);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user