fix(configuration-database): resolve ConfigurationDatabase-005,006,008,009,010,011 — bounded gRPC columns, split queries, CSV-parse logging, null guards, coverage
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 6 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ follow-up. The code fix in this module is complete.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditConfiguration.cs:11` (entity `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`) |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditConfiguration.cs:11` (entity `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`) |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -282,7 +282,20 @@ Resolve the discrepancy in one direction.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
|
||||||
|
`AuditLogEntry` entity declares `int Id`, while the design doc's Audit Entry Schema
|
||||||
|
table said `Long / GUID`. The entity lives in `ScadaLink.Commons`
|
||||||
|
(`src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`), which is outside this
|
||||||
|
module's editable scope, so the discrepancy was resolved by aligning the design doc to
|
||||||
|
the code — the recommendation's second option. The schema table now records `Id` as
|
||||||
|
`int (identity)` with an explicit justification: a 32-bit identity matches the key type
|
||||||
|
of every other entity in the schema (uniform repository/query code), and at a sustained
|
||||||
|
100 rows/second the `int` range is not exhausted for roughly 680 years, so the
|
||||||
|
indefinite-retention policy poses no realistic overflow risk; if a future deployment
|
||||||
|
ever approaches the limit the column can be widened to `bigint` via a migration without
|
||||||
|
a schema redesign. No regression test is meaningful for a documentation alignment; the
|
||||||
|
existing `AuditConfiguration` (`HasKey(a => a.Id)`) and the audit repository tests
|
||||||
|
already exercise the `int` key end to end.
|
||||||
|
|
||||||
### ConfigurationDatabase-006 — `Site.GrpcNodeAAddress` / `GrpcNodeBAddress` columns are unbounded
|
### ConfigurationDatabase-006 — `Site.GrpcNodeAAddress` / `GrpcNodeBAddress` columns are unbounded
|
||||||
|
|
||||||
@@ -290,7 +303,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs:24-25` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs:24-25` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -310,7 +323,19 @@ generate a migration to alter the column types.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||||||
|
`SiteConfiguration` configured `NodeAAddress`/`NodeBAddress` with `HasMaxLength(500)` but
|
||||||
|
left `GrpcNodeAAddress`/`GrpcNodeBAddress` unconfigured, so EF mapped them to
|
||||||
|
`nvarchar(max)` — inconsistent with the sibling columns and non-indexable. Applied the
|
||||||
|
recommendation: added `builder.Property(s => s.GrpcNodeAAddress).HasMaxLength(500)` and
|
||||||
|
the same for `GrpcNodeBAddress`. Generated migration
|
||||||
|
`20260517020720_BoundGrpcNodeAddressLength` altering both columns from `nvarchar(max)`
|
||||||
|
to `nvarchar(500)` (the model snapshot was updated to match). Regression tests added in
|
||||||
|
`SchemaConfigurationTests.cs`:
|
||||||
|
`GrpcNodeAddressColumns_AreLengthBoundedTo500` (theory over both columns, asserting the
|
||||||
|
EF model metadata reports `MaxLength == 500`) and
|
||||||
|
`GrpcNodeAddressColumns_MatchSiblingNodeAddressBounds` (asserting the gRPC columns share
|
||||||
|
the bound of the `NodeAAddress`/`NodeBAddress` siblings).
|
||||||
|
|
||||||
### ConfigurationDatabase-007 — `AuditService` does not handle JSON-serialization failure of arbitrary `afterState`
|
### ConfigurationDatabase-007 — `AuditService` does not handle JSON-serialization failure of arbitrary `afterState`
|
||||||
|
|
||||||
@@ -367,7 +392,7 @@ added in `AuditServiceTests.cs`:
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:46-58` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:46-58` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -390,7 +415,25 @@ gives referential integrity and correct cascade behaviour when an API key is del
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||||||
|
`GetApprovedKeysForMethodAsync` mapped each CSV token with
|
||||||
|
`int.TryParse(...) ? id : -1` then filtered `id > 0`, so any unparseable (or
|
||||||
|
non-positive) token was discarded with no signal — a corrupt `ApprovedApiKeyIds` value
|
||||||
|
silently approves fewer keys than intended, an authorization-relevant outcome.
|
||||||
|
|
||||||
|
Applied the recommendation's short-term fix: the parse loop was rewritten to log a
|
||||||
|
warning for every token that fails to parse to a positive integer, naming the method id
|
||||||
|
and the offending token, so corruption is observable in logs. Valid ids still resolve
|
||||||
|
normally. `InboundApiRepository` gained an optional `ILogger<InboundApiRepository>`
|
||||||
|
constructor parameter (defaulting to `NullLogger`, matching the `MigrationHelper`
|
||||||
|
pattern) and the project now references `Microsoft.Extensions.Logging.Abstractions`. The
|
||||||
|
longer-term join-table redesign would change the `ApiMethod` entity / schema and the
|
||||||
|
`IInboundApiRepository` contract (Commons, out of this module's scope) and is left as a
|
||||||
|
future schema-design item. Regression tests added in `InboundApiRepositoryTests.cs`:
|
||||||
|
`GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken`,
|
||||||
|
`GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys`, and
|
||||||
|
`GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning` (using a
|
||||||
|
capturing `ILogger` to assert the warning is emitted only on malformed input).
|
||||||
|
|
||||||
### ConfigurationDatabase-009 — Multi-collection eager loads issue cartesian-product queries
|
### ConfigurationDatabase-009 — Multi-collection eager loads issue cartesian-product queries
|
||||||
|
|
||||||
@@ -398,7 +441,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:43-51,53-61`, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:45-55` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:43-51,53-61`, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:45-55` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -421,7 +464,24 @@ cartesian explosion is avoided.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||||||
|
`GetAllTemplatesAsync` and `GetTemplatesComposingAsync` (`TemplateEngineRepository`) and
|
||||||
|
`GetTemplateTreeAsync` (`CentralUiRepository`) each `Include` three-to-four sibling
|
||||||
|
collections in a single query, producing a cartesian-product join. The same shape was
|
||||||
|
also present in `GetTemplateByIdAsync`, `GetInstanceByIdAsync`, `GetAllInstancesAsync`,
|
||||||
|
`GetInstancesBySiteIdAsync`, and `GetInstanceByUniqueNameAsync`.
|
||||||
|
|
||||||
|
Applied the recommendation's per-query option: `.AsSplitQuery()` was added to every
|
||||||
|
multi-collection-include query in `TemplateEngineRepository` (eight call sites) and to
|
||||||
|
`GetTemplateTreeAsync` in `CentralUiRepository`, so each collection loads with its own
|
||||||
|
query and the cartesian explosion is avoided. Per-query `AsSplitQuery()` was preferred
|
||||||
|
over a global `UseQuerySplittingBehavior` so single-collection queries elsewhere keep
|
||||||
|
the cheaper single-query plan. Split queries change query *shape* only, not results;
|
||||||
|
regression tests added in `SchemaConfigurationTests.cs` pin that behaviour:
|
||||||
|
`GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication`
|
||||||
|
(a template with 3 attributes, 2 alarms, 4 scripts must return exactly those counts —
|
||||||
|
not a 24-row cartesian product) and
|
||||||
|
`GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections`.
|
||||||
|
|
||||||
### ConfigurationDatabase-010 — Several repositories and `InstanceLocator` lack direct test coverage
|
### ConfigurationDatabase-010 — Several repositories and `InstanceLocator` lack direct test coverage
|
||||||
|
|
||||||
@@ -429,7 +489,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`, `Repositories/DeploymentManagerRepository.cs`, `Repositories/ExternalSystemRepository.cs`, `Repositories/InboundApiRepository.cs`, `Repositories/NotificationRepository.cs`, `Repositories/SiteRepository.cs`, `Services/InstanceLocator.cs` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`, `Repositories/DeploymentManagerRepository.cs`, `Repositories/ExternalSystemRepository.cs`, `Repositories/InboundApiRepository.cs`, `Repositories/NotificationRepository.cs`, `Repositories/SiteRepository.cs`, `Services/InstanceLocator.cs` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -453,7 +513,24 @@ and `InstanceLocator.GetSiteIdForInstanceAsync` for found/not-found cases.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Direct repository/service tests were added using
|
||||||
|
the existing `SqliteTestHelper` pattern. `InboundApiRepositoryTests.cs` covers
|
||||||
|
`InboundApiRepository` (API-key/method CRUD round-trips and the
|
||||||
|
`GetApprovedKeysForMethodAsync` valid/malformed/empty-CSV cases — see CD-008).
|
||||||
|
`RepositoryCoverageTests.cs` adds `ExternalSystemRepositoryTests` (definition/method CRUD,
|
||||||
|
parent-filtered method query, database-connection delete), `NotificationRepositoryTests`
|
||||||
|
(notification-list-with-recipients and SMTP-configuration round-trips, list delete),
|
||||||
|
`SiteRepositoryTests` (site/identifier round-trip plus the stub-attach delete fallback
|
||||||
|
exercised for both `DeleteSiteAsync` and `DeleteDataConnectionAsync` by clearing the
|
||||||
|
ChangeTracker, and the site-filtered instance query), `DeploymentManagerRepositoryTests`
|
||||||
|
(deployment-record CRUD and `GetCurrentDeploymentStatusAsync` ordering, the stub-attach
|
||||||
|
`DeleteDeploymentRecordAsync` fallback, and `DeleteInstanceAsync`'s explicit
|
||||||
|
Restrict-FK deployment-record cleanup), and `InstanceLocatorTests`
|
||||||
|
(`GetSiteIdForInstanceAsync` for the found and not-found cases). `TemplateEngineRepository`
|
||||||
|
gained the CD-001 and CD-009 regression tests
|
||||||
|
(`TemplateEngineRepositoryTests.cs`, `SchemaConfigurationTests.cs`). A constructor
|
||||||
|
null-guard test was added for each of the five repositories/services covered, doubling
|
||||||
|
as the CD-011 regression guard. The full module suite is green.
|
||||||
|
|
||||||
### ConfigurationDatabase-011 — Inconsistent constructor null-guarding across repositories/services
|
### ConfigurationDatabase-011 — Inconsistent constructor null-guarding across repositories/services
|
||||||
|
|
||||||
@@ -461,7 +538,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs:11-14`, `Repositories/InboundApiRepository.cs:11-14`, `Repositories/NotificationRepository.cs:11-14`, `Services/InstanceLocator.cs:13-16` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs:11-14`, `Repositories/InboundApiRepository.cs:11-14`, `Repositories/NotificationRepository.cs:11-14`, `Services/InstanceLocator.cs:13-16` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -482,4 +559,14 @@ inconsistent constructors so all data-access types behave uniformly.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||||||
|
`ExternalSystemRepository`, `InboundApiRepository`, `NotificationRepository`, and
|
||||||
|
`InstanceLocator` assigned the injected `ScadaLinkDbContext` directly with no null
|
||||||
|
guard, diverging from `SecurityRepository`/`CentralUiRepository`/`TemplateEngineRepository`/
|
||||||
|
`DeploymentManagerRepository`/`SiteRepository`/`AuditService`. Applied the recommendation:
|
||||||
|
all four constructors now use `context ?? throw new ArgumentNullException(nameof(context))`
|
||||||
|
(`InboundApiRepository`'s guard was added as part of its CD-008 constructor change), so
|
||||||
|
every data-access type behaves uniformly and a hand-constructed instance fails with an
|
||||||
|
informative exception at construction rather than a later `NullReferenceException`.
|
||||||
|
Regression: `Constructor_NullContext_Throws` tests were added for all four affected types
|
||||||
|
(`InboundApiRepositoryTests.cs`, `RepositoryCoverageTests.cs`).
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ Template Engine: Update Template
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| **Id** | Long / GUID | Unique identifier for the audit entry. |
|
| **Id** | int (identity) | Surrogate primary key for the audit entry. A 32-bit `int` identity is used deliberately: it matches the key type of every other entity in the schema (uniform repository and query code), and SQL Server identity values are not consumed by failed transactions in a way that materially accelerates exhaustion. At a sustained, unrealistically high rate of 100 audit rows per second the `int` range is not exhausted for roughly 680 years; the indefinite-retention policy does not change that horizon. If a future deployment genuinely approaches the limit, the column can be widened to `bigint` via a migration without a schema redesign. |
|
||||||
| **Timestamp** | DateTimeOffset | When the action occurred (UTC). |
|
| **Timestamp** | DateTimeOffset | When the action occurred (UTC). |
|
||||||
| **User** | String | Authenticated AD username. |
|
| **User** | String | Authenticated AD username. |
|
||||||
| **Action** | String | The type of operation. |
|
| **Action** | String | The type of operation. |
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class SiteConfiguration : IEntityTypeConfiguration<Site>
|
|||||||
|
|
||||||
builder.Property(s => s.NodeAAddress).HasMaxLength(500);
|
builder.Property(s => s.NodeAAddress).HasMaxLength(500);
|
||||||
builder.Property(s => s.NodeBAddress).HasMaxLength(500);
|
builder.Property(s => s.NodeBAddress).HasMaxLength(500);
|
||||||
|
builder.Property(s => s.GrpcNodeAAddress).HasMaxLength(500);
|
||||||
|
builder.Property(s => s.GrpcNodeBAddress).HasMaxLength(500);
|
||||||
|
|
||||||
builder.HasIndex(s => s.Name).IsUnique();
|
builder.HasIndex(s => s.Name).IsUnique();
|
||||||
builder.HasIndex(s => s.SiteIdentifier).IsUnique();
|
builder.HasIndex(s => s.SiteIdentifier).IsUnique();
|
||||||
|
|||||||
1350
src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.Designer.cs
generated
Normal file
1350
src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class BoundGrpcNodeAddressLength : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GrpcNodeBAddress",
|
||||||
|
table: "Sites",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(max)",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GrpcNodeAAddress",
|
||||||
|
table: "Sites",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(max)",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GrpcNodeBAddress",
|
||||||
|
table: "Sites",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(500)",
|
||||||
|
oldMaxLength: 500,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GrpcNodeAAddress",
|
||||||
|
table: "Sites",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(500)",
|
||||||
|
oldMaxLength: 500,
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -830,10 +830,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.HasColumnType("nvarchar(2000)");
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
b.Property<string>("GrpcNodeAAddress")
|
b.Property<string>("GrpcNodeAAddress")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
b.Property<string>("GrpcNodeBAddress")
|
b.Property<string>("GrpcNodeBAddress")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public class CentralUiRepository : ICentralUiRepository
|
|||||||
.Include(t => t.Alarms)
|
.Include(t => t.Alarms)
|
||||||
.Include(t => t.Scripts)
|
.Include(t => t.Scripts)
|
||||||
.Include(t => t.Compositions)
|
.Include(t => t.Compositions)
|
||||||
|
.AsSplitQuery()
|
||||||
.OrderBy(t => t.Name)
|
.OrderBy(t => t.Name)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
|||||||
.Include(i => i.AttributeOverrides)
|
.Include(i => i.AttributeOverrides)
|
||||||
.Include(i => i.AlarmOverrides)
|
.Include(i => i.AlarmOverrides)
|
||||||
.Include(i => i.ConnectionBindings)
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +181,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
|||||||
.Include(i => i.AttributeOverrides)
|
.Include(i => i.AttributeOverrides)
|
||||||
.Include(i => i.AlarmOverrides)
|
.Include(i => i.AlarmOverrides)
|
||||||
.Include(i => i.ConnectionBindings)
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class ExternalSystemRepository : IExternalSystemRepository
|
|||||||
|
|
||||||
public ExternalSystemRepository(ScadaLinkDbContext context)
|
public ExternalSystemRepository(ScadaLinkDbContext context)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ScadaLink.Commons.Entities.InboundApi;
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
|
||||||
@@ -7,10 +9,12 @@ namespace ScadaLink.ConfigurationDatabase.Repositories;
|
|||||||
public class InboundApiRepository : IInboundApiRepository
|
public class InboundApiRepository : IInboundApiRepository
|
||||||
{
|
{
|
||||||
private readonly ScadaLinkDbContext _context;
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly ILogger<InboundApiRepository> _logger;
|
||||||
|
|
||||||
public InboundApiRepository(ScadaLinkDbContext context)
|
public InboundApiRepository(ScadaLinkDbContext context, ILogger<InboundApiRepository>? logger = null)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
@@ -49,10 +53,26 @@ public class InboundApiRepository : IInboundApiRepository
|
|||||||
if (method?.ApprovedApiKeyIds == null)
|
if (method?.ApprovedApiKeyIds == null)
|
||||||
return new List<ApiKey>();
|
return new List<ApiKey>();
|
||||||
|
|
||||||
var keyIds = method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
// ApprovedApiKeyIds is a comma-separated string of integer ApiKey ids. A token that
|
||||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
// fails to parse indicates a corrupt value: it is dropped (it cannot identify a key),
|
||||||
.Where(id => id > 0)
|
// but the corruption is logged as a warning so it is observable rather than silent.
|
||||||
.ToList();
|
// A corrupt list would otherwise quietly approve fewer keys than intended.
|
||||||
|
var keyIds = new List<int>();
|
||||||
|
foreach (var token in method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var trimmed = token.Trim();
|
||||||
|
if (int.TryParse(trimmed, out var id) && id > 0)
|
||||||
|
{
|
||||||
|
keyIds.Add(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"ApiMethod {MethodId} has a malformed approved-API-key id token '{Token}' " +
|
||||||
|
"in ApprovedApiKeyIds; it was dropped. The method may approve fewer keys than expected.",
|
||||||
|
methodId, trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
|
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class NotificationRepository : INotificationRepository
|
|||||||
|
|
||||||
public NotificationRepository(ScadaLinkDbContext context)
|
public NotificationRepository(ScadaLinkDbContext context)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(t => t.Alarms)
|
.Include(t => t.Alarms)
|
||||||
.Include(t => t.Scripts)
|
.Include(t => t.Scripts)
|
||||||
.Include(t => t.Compositions)
|
.Include(t => t.Compositions)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(t => t.Alarms)
|
.Include(t => t.Alarms)
|
||||||
.Include(t => t.Scripts)
|
.Include(t => t.Scripts)
|
||||||
.Include(t => t.Compositions)
|
.Include(t => t.Compositions)
|
||||||
|
.AsSplitQuery()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(t => t.Attributes)
|
.Include(t => t.Attributes)
|
||||||
.Include(t => t.Scripts)
|
.Include(t => t.Scripts)
|
||||||
.Include(t => t.Compositions)
|
.Include(t => t.Compositions)
|
||||||
|
.AsSplitQuery()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +225,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(i => i.AttributeOverrides)
|
.Include(i => i.AttributeOverrides)
|
||||||
.Include(i => i.AlarmOverrides)
|
.Include(i => i.AlarmOverrides)
|
||||||
.Include(i => i.ConnectionBindings)
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +235,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(i => i.AttributeOverrides)
|
.Include(i => i.AttributeOverrides)
|
||||||
.Include(i => i.AlarmOverrides)
|
.Include(i => i.AlarmOverrides)
|
||||||
.Include(i => i.ConnectionBindings)
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.AsSplitQuery()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +253,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(i => i.AttributeOverrides)
|
.Include(i => i.AttributeOverrides)
|
||||||
.Include(i => i.AlarmOverrides)
|
.Include(i => i.AlarmOverrides)
|
||||||
.Include(i => i.ConnectionBindings)
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.AsSplitQuery()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +263,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.Include(i => i.AttributeOverrides)
|
.Include(i => i.AttributeOverrides)
|
||||||
.Include(i => i.AlarmOverrides)
|
.Include(i => i.AlarmOverrides)
|
||||||
.Include(i => i.ConnectionBindings)
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class InstanceLocator : IInstanceLocator
|
|||||||
|
|
||||||
public InstanceLocator(ScadaLinkDbContext context)
|
public InstanceLocator(ScadaLinkDbContext context)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetSiteIdForInstanceAsync(
|
public async Task<string?> GetSiteIdForInstanceAsync(
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
|
public class InboundApiRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly CapturingLogger<InboundApiRepository> _logger = new();
|
||||||
|
private readonly InboundApiRepository _repository;
|
||||||
|
|
||||||
|
public InboundApiRepositoryTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new InboundApiRepository(_context, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddApiKey_AndGetById_RoundTrips()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("Key1", "secret-value-1") { IsEnabled = true };
|
||||||
|
await _repository.AddApiKeyAsync(key);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetApiKeyByIdAsync(key.Id);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("Key1", loaded!.Name);
|
||||||
|
|
||||||
|
var byValue = await _repository.GetApiKeyByValueAsync("secret-value-1");
|
||||||
|
Assert.NotNull(byValue);
|
||||||
|
Assert.Equal(key.Id, byValue!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddApiMethod_AndGetByName_RoundTrips()
|
||||||
|
{
|
||||||
|
var method = new ApiMethod("DoThing", "return 1;");
|
||||||
|
await _repository.AddApiMethodAsync(method);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetMethodByNameAsync("DoThing");
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(method.Id, loaded!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys()
|
||||||
|
{
|
||||||
|
var k1 = new ApiKey("K1", "v1");
|
||||||
|
var k2 = new ApiKey("K2", "v2");
|
||||||
|
await _repository.AddApiKeyAsync(k1);
|
||||||
|
await _repository.AddApiKeyAsync(k2);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id}, {k2.Id}" };
|
||||||
|
await _repository.AddApiMethodAsync(method);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||||
|
|
||||||
|
Assert.Equal(2, keys.Count);
|
||||||
|
Assert.Empty(_logger.Warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken()
|
||||||
|
{
|
||||||
|
// Regression guard for ConfigurationDatabase-008: a corrupt token (a name where an
|
||||||
|
// integer id is expected) must not be dropped silently — the corruption must be
|
||||||
|
// observable via a logged warning, while the valid ids still resolve.
|
||||||
|
var k1 = new ApiKey("K1", "v1");
|
||||||
|
await _repository.AddApiKeyAsync(k1);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id},not-an-id" };
|
||||||
|
await _repository.AddApiMethodAsync(method);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||||
|
|
||||||
|
Assert.Single(keys);
|
||||||
|
Assert.Equal(k1.Id, keys[0].Id);
|
||||||
|
Assert.Single(_logger.Warnings);
|
||||||
|
Assert.Contains("not-an-id", _logger.Warnings[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning()
|
||||||
|
{
|
||||||
|
var method = new ApiMethod("M", "return 1;");
|
||||||
|
await _repository.AddApiMethodAsync(method);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||||
|
|
||||||
|
Assert.Empty(keys);
|
||||||
|
Assert.Empty(_logger.Warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteApiMethod_RemovesEntity()
|
||||||
|
{
|
||||||
|
var method = new ApiMethod("ToDelete", "return 1;");
|
||||||
|
await _repository.AddApiMethodAsync(method);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _repository.DeleteApiMethodAsync(method.Id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetApiMethodByIdAsync(method.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new InboundApiRepository(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal ILogger that captures warning-level messages for assertions.</summary>
|
||||||
|
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<string> Warnings { get; } = new();
|
||||||
|
|
||||||
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (logLevel == LogLevel.Warning)
|
||||||
|
{
|
||||||
|
Warnings.Add(formatter(state, exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullScope Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Deployment;
|
||||||
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
|
using ScadaLink.Commons.Entities.Instances;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
|
// Regression coverage for ConfigurationDatabase-010 (repositories / InstanceLocator lacked
|
||||||
|
// direct tests) and ConfigurationDatabase-011 (inconsistent constructor null-guarding).
|
||||||
|
|
||||||
|
public class ExternalSystemRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly ExternalSystemRepository _repository;
|
||||||
|
|
||||||
|
public ExternalSystemRepositoryTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new ExternalSystemRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddExternalSystem_AndGetById_RoundTrips()
|
||||||
|
{
|
||||||
|
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
|
||||||
|
await _repository.AddExternalSystemAsync(def);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetExternalSystemByIdAsync(def.Id);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("Sys", loaded!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMethodsByExternalSystemId_FiltersByParent()
|
||||||
|
{
|
||||||
|
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
|
||||||
|
await _repository.AddExternalSystemAsync(def);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _repository.AddExternalSystemMethodAsync(
|
||||||
|
new ExternalSystemMethod("M1", "GET", "/m1") { ExternalSystemDefinitionId = def.Id });
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var methods = await _repository.GetMethodsByExternalSystemIdAsync(def.Id);
|
||||||
|
Assert.Single(methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteDatabaseConnection_RemovesEntity()
|
||||||
|
{
|
||||||
|
var conn = new DatabaseConnectionDefinition("Db", "Server=x;Database=y;");
|
||||||
|
await _repository.AddDatabaseConnectionAsync(conn);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _repository.DeleteDatabaseConnectionAsync(conn.Id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly NotificationRepository _repository;
|
||||||
|
|
||||||
|
public NotificationRepositoryTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new NotificationRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddNotificationList_WithRecipients_RoundTrips()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("Ops");
|
||||||
|
list.Recipients.Add(new NotificationRecipient("Ops Team", "ops@example.test"));
|
||||||
|
await _repository.AddNotificationListAsync(list);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetListByNameAsync("Ops");
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
|
||||||
|
var all = await _repository.GetAllNotificationListsAsync();
|
||||||
|
Assert.Single(all);
|
||||||
|
Assert.Single(all[0].Recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddSmtpConfiguration_AndGetById_RoundTrips()
|
||||||
|
{
|
||||||
|
var smtp = new SmtpConfiguration("smtp.example.test", "Basic", "from@example.test");
|
||||||
|
await _repository.AddSmtpConfigurationAsync(smtp);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetSmtpConfigurationByIdAsync(smtp.Id);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("smtp.example.test", loaded!.Host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteNotificationList_RemovesEntity()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ToDelete");
|
||||||
|
await _repository.AddNotificationListAsync(list);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _repository.DeleteNotificationListAsync(list.Id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new NotificationRepository(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SiteRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly SiteRepository _repository;
|
||||||
|
|
||||||
|
public SiteRepositoryTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new SiteRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddSite_AndGetByIdentifier_RoundTrips()
|
||||||
|
{
|
||||||
|
var site = new Site("Site1", "S-001");
|
||||||
|
await _repository.AddSiteAsync(site);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetSiteByIdentifierAsync("S-001");
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("Site1", loaded!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSite_ViaStubAttachPath_RemovesEntity()
|
||||||
|
{
|
||||||
|
// Exercises the stub-attach delete fallback: the entity is not tracked because the
|
||||||
|
// ChangeTracker is cleared, forcing the Local-miss branch in DeleteSiteAsync.
|
||||||
|
var site = new Site("Site1", "S-001");
|
||||||
|
await _repository.AddSiteAsync(site);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
var id = site.Id;
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
await _repository.DeleteSiteAsync(id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetSiteByIdAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity()
|
||||||
|
{
|
||||||
|
var site = new Site("Site1", "S-001");
|
||||||
|
await _repository.AddSiteAsync(site);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
||||||
|
await _repository.AddDataConnectionAsync(conn);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
var id = conn.Id;
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
await _repository.DeleteDataConnectionAsync(id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetDataConnectionByIdAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetInstancesBySiteId_FiltersBySite()
|
||||||
|
{
|
||||||
|
var site = new Site("Site1", "S-001");
|
||||||
|
var template = new Template("T1");
|
||||||
|
_context.Sites.Add(site);
|
||||||
|
_context.Templates.Add(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var instances = await _repository.GetInstancesBySiteIdAsync(site.Id);
|
||||||
|
Assert.Single(instances);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new SiteRepository(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DeploymentManagerRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly DeploymentManagerRepository _repository;
|
||||||
|
|
||||||
|
public DeploymentManagerRepositoryTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new DeploymentManagerRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Instance> SeedInstanceAsync()
|
||||||
|
{
|
||||||
|
var site = new Site("Site1", "S-001");
|
||||||
|
var template = new Template("T1");
|
||||||
|
_context.Sites.Add(site);
|
||||||
|
_context.Templates.Add(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
|
||||||
|
_context.Instances.Add(instance);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent()
|
||||||
|
{
|
||||||
|
var instance = await SeedInstanceAsync();
|
||||||
|
|
||||||
|
await _repository.AddDeploymentRecordAsync(
|
||||||
|
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) });
|
||||||
|
await _repository.AddDeploymentRecordAsync(
|
||||||
|
new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
|
||||||
|
Assert.NotNull(current);
|
||||||
|
Assert.Equal("d-002", current!.DeploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity()
|
||||||
|
{
|
||||||
|
var instance = await SeedInstanceAsync();
|
||||||
|
var record = new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow };
|
||||||
|
await _repository.AddDeploymentRecordAsync(record);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
var id = record.Id;
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
await _repository.DeleteDeploymentRecordAsync(id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
|
||||||
|
{
|
||||||
|
// DeploymentRecord has a Restrict FK to Instance; DeleteInstanceAsync must remove
|
||||||
|
// the dependent deployment records explicitly or the delete would fail.
|
||||||
|
var instance = await SeedInstanceAsync();
|
||||||
|
await _repository.AddDeploymentRecordAsync(
|
||||||
|
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _repository.DeleteInstanceAsync(instance.Id);
|
||||||
|
await _repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Null(await _repository.GetInstanceByIdAsync(instance.Id));
|
||||||
|
Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new DeploymentManagerRepository(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InstanceLocatorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly InstanceLocator _locator;
|
||||||
|
|
||||||
|
public InstanceLocatorTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_locator = new InstanceLocator(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier()
|
||||||
|
{
|
||||||
|
var site = new Site("Site1", "SITE-001");
|
||||||
|
var template = new Template("T1");
|
||||||
|
_context.Sites.Add(site);
|
||||||
|
_context.Templates.Add(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_context.Instances.Add(new Instance("Pump1") { SiteId = site.Id, TemplateId = template.Id });
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _locator.GetSiteIdForInstanceAsync("Pump1");
|
||||||
|
Assert.Equal("SITE-001", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = await _locator.GetSiteIdForInstanceAsync("DoesNotExist");
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new InstanceLocator(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
|
public class SchemaConfigurationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
|
||||||
|
public SchemaConfigurationTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigurationDatabase-006: the gRPC node-address columns must be length-bounded
|
||||||
|
// (HasMaxLength(500)) consistently with the sibling NodeAAddress/NodeBAddress columns,
|
||||||
|
// rather than being left to map to nvarchar(max).
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(nameof(Site.GrpcNodeAAddress))]
|
||||||
|
[InlineData(nameof(Site.GrpcNodeBAddress))]
|
||||||
|
public void GrpcNodeAddressColumns_AreLengthBoundedTo500(string propertyName)
|
||||||
|
{
|
||||||
|
var property = _context.Model
|
||||||
|
.FindEntityType(typeof(Site))!
|
||||||
|
.FindProperty(propertyName)!;
|
||||||
|
|
||||||
|
Assert.Equal(500, property.GetMaxLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(nameof(Site.NodeAAddress))]
|
||||||
|
[InlineData(nameof(Site.NodeBAddress))]
|
||||||
|
public void GrpcNodeAddressColumns_MatchSiblingNodeAddressBounds(string siblingPropertyName)
|
||||||
|
{
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(Site))!;
|
||||||
|
var siblingMaxLength = entity.FindProperty(siblingPropertyName)!.GetMaxLength();
|
||||||
|
|
||||||
|
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeAAddress))!.GetMaxLength());
|
||||||
|
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SplitQueryBehaviourTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly TemplateEngineRepository _repository;
|
||||||
|
|
||||||
|
public SplitQueryBehaviourTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new TemplateEngineRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigurationDatabase-009: the multi-collection eager-load queries were switched to
|
||||||
|
// AsSplitQuery() to avoid cartesian-product joins. The result set must be unchanged —
|
||||||
|
// every member collection still fully populated, with no row duplication.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication()
|
||||||
|
{
|
||||||
|
var template = new Template("MultiMember");
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
template.Attributes.Add(new TemplateAttribute($"Attr{i}"));
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
template.Alarms.Add(new TemplateAlarm($"Alarm{i}"));
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
template.Scripts.Add(new TemplateScript($"Script{i}", "return 1;"));
|
||||||
|
_context.Templates.Add(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var all = await _repository.GetAllTemplatesAsync();
|
||||||
|
|
||||||
|
var loaded = Assert.Single(all);
|
||||||
|
// A cartesian-product single query would yield 3 x 2 x 4 = 24 joined rows; the
|
||||||
|
// collections must still contain exactly the inserted counts.
|
||||||
|
Assert.Equal(3, loaded.Attributes.Count);
|
||||||
|
Assert.Equal(2, loaded.Alarms.Count);
|
||||||
|
Assert.Equal(4, loaded.Scripts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections()
|
||||||
|
{
|
||||||
|
var template = new Template("Single");
|
||||||
|
template.Attributes.Add(new TemplateAttribute("A1"));
|
||||||
|
template.Attributes.Add(new TemplateAttribute("A2"));
|
||||||
|
template.Scripts.Add(new TemplateScript("S1", "return 1;"));
|
||||||
|
_context.Templates.Add(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetTemplateByIdAsync(template.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(2, loaded!.Attributes.Count);
|
||||||
|
Assert.Single(loaded.Scripts);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user