From 7d1cc5cbb479358e3eecaf22d9d18a4b62aeeeed Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 22:14:23 -0400 Subject: [PATCH] =?UTF-8?q?fix(configuration-database):=20resolve=20Config?= =?UTF-8?q?urationDatabase-005,006,008,009,010,011=20=E2=80=94=20bounded?= =?UTF-8?q?=20gRPC=20columns,=20split=20queries,=20CSV-parse=20logging,=20?= =?UTF-8?q?null=20guards,=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigurationDatabase/findings.md | 113 +- .../Component-ConfigurationDatabase.md | 2 +- .../Configurations/SiteConfiguration.cs | 2 + ...720_BoundGrpcNodeAddressLength.Designer.cs | 1350 +++++++++++++++++ ...260517020720_BoundGrpcNodeAddressLength.cs | 58 + .../ScadaLinkDbContextModelSnapshot.cs | 6 +- .../Repositories/CentralUiRepository.cs | 1 + .../DeploymentManagerRepository.cs | 2 + .../Repositories/ExternalSystemRepository.cs | 2 +- .../Repositories/InboundApiRepository.cs | 32 +- .../Repositories/NotificationRepository.cs | 2 +- .../Repositories/TemplateEngineRepository.cs | 7 + .../ScadaLink.ConfigurationDatabase.csproj | 1 + .../Services/InstanceLocator.cs | 2 +- .../InboundApiRepositoryTests.cs | 152 ++ .../RepositoryCoverageTests.cs | 366 +++++ .../SchemaConfigurationTests.cs | 115 ++ 17 files changed, 2188 insertions(+), 25 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs diff --git a/code-reviews/ConfigurationDatabase/findings.md b/code-reviews/ConfigurationDatabase/findings.md index d1b1615..b05a98b 100644 --- a/code-reviews/ConfigurationDatabase/findings.md +++ b/code-reviews/ConfigurationDatabase/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 6 | +| Open findings | 0 | ## Summary @@ -261,7 +261,7 @@ follow-up. The code fix in this module is complete. |--|--| | Severity | Low | | Category | Design-document adherence | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditConfiguration.cs:11` (entity `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`) | **Description** @@ -282,7 +282,20 @@ Resolve the discrepancy in one direction. **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 @@ -290,7 +303,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Code organization & conventions | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs:24-25` | **Description** @@ -310,7 +323,19 @@ generate a migration to alter the column types. **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` @@ -367,7 +392,7 @@ added in `AuditServiceTests.cs`: |--|--| | Severity | Low | | Category | Correctness & logic bugs | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:46-58` | **Description** @@ -390,7 +415,25 @@ gives referential integrity and correct cascade behaviour when an API key is del **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` +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 @@ -398,7 +441,7 @@ _Unresolved._ |--|--| | Severity | Low | | 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` | **Description** @@ -421,7 +464,24 @@ cartesian explosion is avoided. **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 @@ -429,7 +489,7 @@ _Unresolved._ |--|--| | Severity | Low | | 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` | **Description** @@ -453,7 +513,24 @@ and `InstanceLocator.GetSiteIdForInstanceAsync` for found/not-found cases. **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 @@ -461,7 +538,7 @@ _Unresolved._ |--|--| | Severity | Low | | 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` | **Description** @@ -482,4 +559,14 @@ inconsistent constructors so all data-access types behave uniformly. **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`). diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md index dada6b0..5bcb3a4 100644 --- a/docs/requirements/Component-ConfigurationDatabase.md +++ b/docs/requirements/Component-ConfigurationDatabase.md @@ -166,7 +166,7 @@ Template Engine: Update Template | 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). | | **User** | String | Authenticated AD username. | | **Action** | String | The type of operation. | diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs index abfe7bb..41d455f 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs @@ -23,6 +23,8 @@ public class SiteConfiguration : IEntityTypeConfiguration builder.Property(s => s.NodeAAddress).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.SiteIdentifier).IsUnique(); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.Designer.cs new file mode 100644 index 0000000..df4776b --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.Designer.cs @@ -0,0 +1,1350 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260517020720_BoundGrpcNodeAddressLength")] + partial class BoundGrpcNodeAddressLength + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyValue") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.cs new file mode 100644 index 0000000..9056aaa --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517020720_BoundGrpcNodeAddressLength.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + public partial class BoundGrpcNodeAddressLength : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GrpcNodeBAddress", + table: "Sites", + type: "nvarchar(500)", + maxLength: 500, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "GrpcNodeAAddress", + table: "Sites", + type: "nvarchar(500)", + maxLength: 500, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GrpcNodeBAddress", + table: "Sites", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(500)", + oldMaxLength: 500, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "GrpcNodeAAddress", + table: "Sites", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(500)", + oldMaxLength: 500, + oldNullable: true); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index a29759c..3955bdd 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -830,10 +830,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasColumnType("nvarchar(2000)"); b.Property("GrpcNodeAAddress") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); b.Property("GrpcNodeBAddress") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); b.Property("Name") .IsRequired() diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs index 0a8acb7..7973cef 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs @@ -50,6 +50,7 @@ public class CentralUiRepository : ICentralUiRepository .Include(t => t.Alarms) .Include(t => t.Scripts) .Include(t => t.Compositions) + .AsSplitQuery() .OrderBy(t => t.Name) .ToListAsync(cancellationToken); } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs index de77c8b..5f21279 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs @@ -171,6 +171,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) + .AsSplitQuery() .FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken); } @@ -180,6 +181,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) + .AsSplitQuery() .FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken); } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs index 30e894e..746d419 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs @@ -10,7 +10,7 @@ public class ExternalSystemRepository : IExternalSystemRepository public ExternalSystemRepository(ScadaLinkDbContext context) { - _context = context; + _context = context ?? throw new ArgumentNullException(nameof(context)); } public async Task GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default) diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs index 4caf657..718e7d8 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Repositories; @@ -7,10 +9,12 @@ namespace ScadaLink.ConfigurationDatabase.Repositories; public class InboundApiRepository : IInboundApiRepository { private readonly ScadaLinkDbContext _context; + private readonly ILogger _logger; - public InboundApiRepository(ScadaLinkDbContext context) + public InboundApiRepository(ScadaLinkDbContext context, ILogger? logger = null) { - _context = context; + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? NullLogger.Instance; } public async Task GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default) @@ -49,10 +53,26 @@ public class InboundApiRepository : IInboundApiRepository if (method?.ApprovedApiKeyIds == null) return new List(); - var keyIds = method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => int.TryParse(s.Trim(), out var id) ? id : -1) - .Where(id => id > 0) - .ToList(); + // ApprovedApiKeyIds is a comma-separated string of integer ApiKey ids. A token that + // fails to parse indicates a corrupt value: it is dropped (it cannot identify a key), + // but the corruption is logged as a warning so it is observable rather than silent. + // A corrupt list would otherwise quietly approve fewer keys than intended. + var keyIds = new List(); + 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().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken); } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationRepository.cs index 30a47d8..c1b3d33 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationRepository.cs @@ -10,7 +10,7 @@ public class NotificationRepository : INotificationRepository public NotificationRepository(ScadaLinkDbContext context) { - _context = context; + _context = context ?? throw new ArgumentNullException(nameof(context)); } public async Task GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default) diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs index ea36949..f632787 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs @@ -24,6 +24,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(t => t.Alarms) .Include(t => t.Scripts) .Include(t => t.Compositions) + .AsSplitQuery() .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); } @@ -45,6 +46,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(t => t.Alarms) .Include(t => t.Scripts) .Include(t => t.Compositions) + .AsSplitQuery() .ToListAsync(cancellationToken); } @@ -55,6 +57,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(t => t.Attributes) .Include(t => t.Scripts) .Include(t => t.Compositions) + .AsSplitQuery() .ToListAsync(cancellationToken); } @@ -222,6 +225,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) + .AsSplitQuery() .FirstOrDefaultAsync(i => i.Id == id, cancellationToken); } @@ -231,6 +235,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) + .AsSplitQuery() .ToListAsync(cancellationToken); } @@ -248,6 +253,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) + .AsSplitQuery() .ToListAsync(cancellationToken); } @@ -257,6 +263,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) + .AsSplitQuery() .FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken); } diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj b/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj index 88e685e..b41005a 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj @@ -16,6 +16,7 @@ + diff --git a/src/ScadaLink.ConfigurationDatabase/Services/InstanceLocator.cs b/src/ScadaLink.ConfigurationDatabase/Services/InstanceLocator.cs index d257d24..5087326 100644 --- a/src/ScadaLink.ConfigurationDatabase/Services/InstanceLocator.cs +++ b/src/ScadaLink.ConfigurationDatabase/Services/InstanceLocator.cs @@ -12,7 +12,7 @@ public class InstanceLocator : IInstanceLocator public InstanceLocator(ScadaLinkDbContext context) { - _context = context; + _context = context ?? throw new ArgumentNullException(nameof(context)); } public async Task GetSiteIdForInstanceAsync( diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs new file mode 100644 index 0000000..d92994a --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs @@ -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 _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(() => new InboundApiRepository(null!)); + } +} + +/// Minimal ILogger that captures warning-level messages for assertions. +internal sealed class CapturingLogger : ILogger +{ + public List Warnings { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs new file mode 100644 index 0000000..bf4370d --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -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(() => 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(() => 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(() => 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 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(() => 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(() => new InstanceLocator(null!)); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs new file mode 100644 index 0000000..81bdbdb --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs @@ -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); + } +}