feat(db): PendingSecuredWrite entity + migration + repository (T14b)
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
|
||||
/// <summary>
|
||||
/// Central operational state row for a two-person ("secured") write through its
|
||||
/// lifecycle (M7 OPC UA / MxGateway UX, Task T14b). One row per pending write in the
|
||||
/// central <c>PendingSecuredWrites</c> MS SQL table — append-once at submission then
|
||||
/// mutated as the request is approved/rejected and executed against the target.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration Database
|
||||
/// component (<c>PendingSecuredWriteEntityTypeConfiguration</c>). Unlike the partitioned
|
||||
/// append-only <c>AuditLog</c> this entity backs mutable operational state on a standard
|
||||
/// non-partitioned table on the <c>[PRIMARY]</c> filegroup; no DB-role restriction
|
||||
/// applies. It mirrors the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/>
|
||||
/// entity/config/repository shape.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All timestamps are UTC, like every timestamp in the system.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PendingSecuredWrite
|
||||
{
|
||||
/// <summary>Surrogate identity key assigned by the store.</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Site id the secured write targets.</summary>
|
||||
public required string SiteId { get; set; }
|
||||
|
||||
/// <summary>Data connection name within the site the write is routed through.</summary>
|
||||
public required string ConnectionName { get; set; }
|
||||
|
||||
/// <summary>Fully-qualified tag path the value is written to.</summary>
|
||||
public required string TagPath { get; set; }
|
||||
|
||||
/// <summary>JSON-serialised value to write (interpreted per <see cref="ValueType"/>).</summary>
|
||||
public required string ValueJson { get; set; }
|
||||
|
||||
/// <summary>The target data type name (e.g. <c>Boolean</c>, <c>Double</c>, <c>String</c>).</summary>
|
||||
public required string ValueType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status — one of
|
||||
/// <c>Pending|Approved|Rejected|Executed|Failed|Expired</c>.
|
||||
/// </summary>
|
||||
public required string Status { get; set; }
|
||||
|
||||
/// <summary>The operator who submitted (requested) the secured write.</summary>
|
||||
public required string OperatorUser { get; set; }
|
||||
|
||||
/// <summary>Optional free-text comment supplied by the requesting operator.</summary>
|
||||
public string? OperatorComment { get; set; }
|
||||
|
||||
/// <summary>UTC instant the secured write was submitted.</summary>
|
||||
public required DateTime SubmittedAtUtc { get; set; }
|
||||
|
||||
/// <summary>The verifier who approved/rejected the write; <c>null</c> while pending.</summary>
|
||||
public string? VerifierUser { get; set; }
|
||||
|
||||
/// <summary>Optional free-text comment supplied by the verifier on decision.</summary>
|
||||
public string? VerifierComment { get; set; }
|
||||
|
||||
/// <summary>UTC instant the write was approved/rejected; <c>null</c> while pending.</summary>
|
||||
public DateTime? DecidedAtUtc { get; set; }
|
||||
|
||||
/// <summary>UTC instant the approved write was executed against the target; <c>null</c> until executed.</summary>
|
||||
public DateTime? ExecutedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Most recent execution error message; <c>null</c> when no failure has occurred.</summary>
|
||||
public string? ExecutionError { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Operational-state data access for the central <c>PendingSecuredWrites</c> table
|
||||
/// (M7 OPC UA / MxGateway UX, Task T14b). One row per pending two-person secured
|
||||
/// write; rows are inserted at submission and mutated as the request is decided and
|
||||
/// executed. Mirrors the <c>SiteCalls</c> (Site Call Audit #22) repository shape.
|
||||
/// </summary>
|
||||
public interface ISecuredWriteRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts <paramref name="securedWrite"/> and returns the store-generated
|
||||
/// <see cref="PendingSecuredWrite.Id"/>.
|
||||
/// </summary>
|
||||
/// <param name="securedWrite">The pending secured write to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the generated identity of the inserted row.</returns>
|
||||
Task<long> AddAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the row for the given id, or <c>null</c> if none exists.
|
||||
/// </summary>
|
||||
/// <param name="id">The identity to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the matching <see cref="PendingSecuredWrite"/>, or <c>null</c> if no row exists.</returns>
|
||||
Task<PendingSecuredWrite?> GetAsync(long id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the current state of <paramref name="securedWrite"/> (matched by
|
||||
/// <see cref="PendingSecuredWrite.Id"/>).
|
||||
/// </summary>
|
||||
/// <param name="securedWrite">The tracked entity whose changes to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task UpdateAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns up to <paramref name="take"/> rows (skipping <paramref name="skip"/>)
|
||||
/// optionally filtered by <paramref name="status"/> and <paramref name="siteId"/>,
|
||||
/// ordered by <c>SubmittedAtUtc DESC, Id DESC</c>. A <c>null</c> filter argument
|
||||
/// matches every row.
|
||||
/// </summary>
|
||||
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
|
||||
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
|
||||
/// <param name="skip">Number of rows to skip (offset paging).</param>
|
||||
/// <param name="take">Maximum number of rows to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a page of matching rows, newest submission first.</returns>
|
||||
Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
|
||||
string? status,
|
||||
string? siteId,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="PendingSecuredWrite"/> entity to the central
|
||||
/// <c>PendingSecuredWrites</c> table (M7 OPC UA / MxGateway UX, Task T14b).
|
||||
/// Operational (mutable) state — NOT audit — so the table is non-partitioned,
|
||||
/// standard <c>[PRIMARY]</c> filegroup, no DB-role restriction. Two named indexes
|
||||
/// back the Central UI's "pending in this status, newest first" and "writes for
|
||||
/// this site" queries. Mirrors <c>SiteCallEntityTypeConfiguration</c>.
|
||||
/// </summary>
|
||||
public class PendingSecuredWriteEntityTypeConfiguration : IEntityTypeConfiguration<PendingSecuredWrite>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the EF Core entity type mapping for <see cref="PendingSecuredWrite"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<PendingSecuredWrite> builder)
|
||||
{
|
||||
builder.ToTable("PendingSecuredWrites");
|
||||
|
||||
// Surrogate identity key (bigint IDENTITY) — generated on insert.
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
// Bounded ASCII identifier columns.
|
||||
builder.Property(p => p.SiteId)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.ConnectionName)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.TagPath)
|
||||
.HasMaxLength(512)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.ValueType)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Enum-as-string lifecycle column (Pending|Approved|Rejected|Executed|Failed|Expired).
|
||||
builder.Property(p => p.Status)
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.OperatorUser)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.VerifierUser)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Larger ASCII payload column — JSON-serialised value to write.
|
||||
builder.Property(p => p.ValueJson)
|
||||
.HasMaxLength(4000)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Operator-facing free-text comments; ASCII, bounded.
|
||||
builder.Property(p => p.OperatorComment)
|
||||
.HasMaxLength(1024)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(p => p.VerifierComment)
|
||||
.HasMaxLength(1024)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Execution error detail; ASCII, bounded a bit larger to capture target faults.
|
||||
builder.Property(p => p.ExecutionError)
|
||||
.HasMaxLength(2048)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Timestamps are UTC datetime2 throughout (EF default for DateTime on SQL Server).
|
||||
builder.Property(p => p.SubmittedAtUtc)
|
||||
.IsRequired();
|
||||
|
||||
// Indexes — names locked for migration/operational discoverability.
|
||||
// Status_Submitted backs "pending writes in this status, newest first".
|
||||
builder.HasIndex(p => new { p.Status, p.SubmittedAtUtc })
|
||||
.HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted");
|
||||
|
||||
// Site backs "secured writes for this site".
|
||||
builder.HasIndex(p => p.SiteId)
|
||||
.HasDatabaseName("IX_PendingSecuredWrites_Site");
|
||||
}
|
||||
}
|
||||
+1877
File diff suppressed because it is too large
Load Diff
+58
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPendingSecuredWriteTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PendingSecuredWrites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
SiteId = table.Column<string>(type: "varchar(128)", unicode: false, maxLength: 128, nullable: false),
|
||||
ConnectionName = table.Column<string>(type: "varchar(128)", unicode: false, maxLength: 128, nullable: false),
|
||||
TagPath = table.Column<string>(type: "varchar(512)", unicode: false, maxLength: 512, nullable: false),
|
||||
ValueJson = table.Column<string>(type: "varchar(4000)", unicode: false, maxLength: 4000, nullable: false),
|
||||
ValueType = table.Column<string>(type: "varchar(128)", unicode: false, maxLength: 128, nullable: false),
|
||||
Status = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
|
||||
OperatorUser = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: false),
|
||||
OperatorComment = table.Column<string>(type: "varchar(1024)", unicode: false, maxLength: 1024, nullable: true),
|
||||
SubmittedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
VerifierUser = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: true),
|
||||
VerifierComment = table.Column<string>(type: "varchar(1024)", unicode: false, maxLength: 1024, nullable: true),
|
||||
DecidedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ExecutedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ExecutionError = table.Column<string>(type: "varchar(2048)", unicode: false, maxLength: 2048, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PendingSecuredWrites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PendingSecuredWrites_Site",
|
||||
table: "PendingSecuredWrites",
|
||||
column: "SiteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PendingSecuredWrites_Status_Submitted",
|
||||
table: "PendingSecuredWrites",
|
||||
columns: new[] { "Status", "SubmittedAtUtc" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PendingSecuredWrites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -927,6 +927,96 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.ToTable("SharedScripts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ConnectionName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<DateTime?>("DecidedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("ExecutedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ExecutionError")
|
||||
.HasMaxLength(2048)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(2048)");
|
||||
|
||||
b.Property<string>("OperatorComment")
|
||||
.HasMaxLength(1024)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(1024)");
|
||||
|
||||
b.Property<string>("OperatorUser")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.Property<string>("SiteId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<DateTime>("SubmittedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("TagPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(512)");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(4000)");
|
||||
|
||||
b.Property<string>("ValueType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<string>("VerifierComment")
|
||||
.HasMaxLength(1024)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(1024)");
|
||||
|
||||
b.Property<string>("VerifierUser")
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SiteId")
|
||||
.HasDatabaseName("IX_PendingSecuredWrites_Site");
|
||||
|
||||
b.HasIndex("Status", "SubmittedAtUtc")
|
||||
.HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted");
|
||||
|
||||
b.ToTable("PendingSecuredWrites", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="ISecuredWriteRepository"/> over the central
|
||||
/// <c>PendingSecuredWrites</c> table (M7 OPC UA / MxGateway UX, Task T14b). Mirrors the
|
||||
/// <c>SiteCallAuditRepository</c> data-access shape: plain tracked EF reads/writes
|
||||
/// against the shared <see cref="ScadaBridgeDbContext"/>, no raw SQL needed.
|
||||
/// </summary>
|
||||
public class SecuredWriteRepository : ISecuredWriteRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecuredWriteRepository"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The EF Core database context.</param>
|
||||
public SecuredWriteRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> AddAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(securedWrite);
|
||||
|
||||
await _context.Set<PendingSecuredWrite>().AddAsync(securedWrite, ct);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return securedWrite.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PendingSecuredWrite?> GetAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Set<PendingSecuredWrite>()
|
||||
.FindAsync(new object?[] { id }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(securedWrite);
|
||||
|
||||
// The caller hands back the same tracked instance it read; Update covers the
|
||||
// detached case too (re-attaches and marks every column modified).
|
||||
_context.Set<PendingSecuredWrite>().Update(securedWrite);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
|
||||
string? status,
|
||||
string? siteId,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<PendingSecuredWrite> query = _context.Set<PendingSecuredWrite>()
|
||||
.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query = query.Where(p => p.Status == status);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(siteId))
|
||||
{
|
||||
query = query.Where(p => p.SiteId == siteId);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(p => p.SubmittedAtUtc)
|
||||
.ThenByDescending(p => p.Id)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
@@ -131,6 +132,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
/// <summary>Gets the set of site calls.</summary>
|
||||
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
||||
|
||||
// Secured Writes (M7 OPC UA / MxGateway UX, T14b)
|
||||
/// <summary>Gets the set of pending two-person secured writes.</summary>
|
||||
public DbSet<PendingSecuredWrite> PendingSecuredWrites => Set<PendingSecuredWrite>();
|
||||
|
||||
// KPI History (M6 "KPI History & Trends")
|
||||
/// <summary>Gets the set of KPI samples (central tall/EAV KPI-history backbone).</summary>
|
||||
public DbSet<KpiSample> KpiSamples => Set<KpiSample>();
|
||||
|
||||
@@ -54,6 +54,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
|
||||
services.AddScoped<ISecuredWriteRepository, SecuredWriteRepository>();
|
||||
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
|
||||
// Auth re-arch (C5): inbound API keys are no longer persisted in SQL Server —
|
||||
// the repository now exposes only API-method access, so a plain scoped
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="SecuredWriteRepository"/> (M7 OPC UA / MxGateway
|
||||
/// UX, Task T14b). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration
|
||||
/// tests so the EF reads/writes execute against the real <c>PendingSecuredWrites</c>
|
||||
/// schema produced by the migration. Each test mints a fresh per-test
|
||||
/// <c>SiteId</c>/<c>Status</c> suffix so tests neither collide nor require teardown.
|
||||
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so the
|
||||
/// runner reports them as Skipped (not Passed) when MSSQL is unreachable.
|
||||
/// </summary>
|
||||
public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public SecuredWriteRepositoryTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Lifecycle_AddGetUpdateQuery_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
|
||||
// Add a Pending row.
|
||||
await using var context = CreateContext();
|
||||
var repo = new SecuredWriteRepository(context);
|
||||
|
||||
var pending = NewRow(siteId, status: "Pending");
|
||||
var id = await repo.AddAsync(pending);
|
||||
Assert.True(id > 0, "AddAsync should return the store-generated identity.");
|
||||
|
||||
// Get returns it.
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var loaded = await new SecuredWriteRepository(readContext).GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(siteId, loaded!.SiteId);
|
||||
Assert.Equal("Pending", loaded.Status);
|
||||
Assert.Equal("conn-1", loaded.ConnectionName);
|
||||
Assert.Equal("Plant.Tank.Setpoint", loaded.TagPath);
|
||||
Assert.Equal("op.alice", loaded.OperatorUser);
|
||||
Assert.Null(loaded.VerifierUser);
|
||||
Assert.Null(loaded.DecidedAtUtc);
|
||||
}
|
||||
|
||||
// Update to Approved.
|
||||
await using (var updateContext = CreateContext())
|
||||
{
|
||||
var updateRepo = new SecuredWriteRepository(updateContext);
|
||||
var toApprove = await updateRepo.GetAsync(id);
|
||||
Assert.NotNull(toApprove);
|
||||
toApprove!.Status = "Approved";
|
||||
toApprove.VerifierUser = "ver.bob";
|
||||
toApprove.VerifierComment = "looks good";
|
||||
toApprove.DecidedAtUtc = DateTime.UtcNow;
|
||||
await updateRepo.UpdateAsync(toApprove);
|
||||
}
|
||||
|
||||
// Get reflects the update.
|
||||
await using (var verifyContext = CreateContext())
|
||||
{
|
||||
var reloaded = await new SecuredWriteRepository(verifyContext).GetAsync(id);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal("Approved", reloaded!.Status);
|
||||
Assert.Equal("ver.bob", reloaded.VerifierUser);
|
||||
Assert.Equal("looks good", reloaded.VerifierComment);
|
||||
Assert.NotNull(reloaded.DecidedAtUtc);
|
||||
}
|
||||
|
||||
// Query by status returns it.
|
||||
await using (var queryContext = CreateContext())
|
||||
{
|
||||
var queryRepo = new SecuredWriteRepository(queryContext);
|
||||
var byStatus = await queryRepo.QueryAsync(status: "Approved", siteId: siteId, skip: 0, take: 50);
|
||||
Assert.Contains(byStatus, p => p.Id == id);
|
||||
Assert.All(byStatus, p => Assert.Equal("Approved", p.Status));
|
||||
|
||||
// A non-matching status filter excludes the row.
|
||||
var pendingPage = await queryRepo.QueryAsync(status: "Pending", siteId: siteId, skip: 0, take: 50);
|
||||
Assert.DoesNotContain(pendingPage, p => p.Id == id);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_NullFilters_MatchEveryStatusForSite()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SecuredWriteRepository(context);
|
||||
|
||||
var pendingId = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
||||
var executedId = await repo.AddAsync(NewRow(siteId, status: "Executed"));
|
||||
|
||||
var all = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 50);
|
||||
Assert.Contains(all, p => p.Id == pendingId);
|
||||
Assert.Contains(all, p => p.Id == executedId);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.Options;
|
||||
return new ScadaBridgeDbContext(options);
|
||||
}
|
||||
|
||||
private static string NewSiteId() =>
|
||||
"site-t14b-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private static PendingSecuredWrite NewRow(string siteId, string status) => new()
|
||||
{
|
||||
SiteId = siteId,
|
||||
ConnectionName = "conn-1",
|
||||
TagPath = "Plant.Tank.Setpoint",
|
||||
ValueJson = "42.5",
|
||||
ValueType = "Double",
|
||||
Status = status,
|
||||
OperatorUser = "op.alice",
|
||||
OperatorComment = "raise setpoint",
|
||||
SubmittedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user