diff --git a/NEW/src/JdeScoping.Api/DependencyInjection.cs b/NEW/src/JdeScoping.Api/DependencyInjection.cs index 15a292f..799b220 100644 --- a/NEW/src/JdeScoping.Api/DependencyInjection.cs +++ b/NEW/src/JdeScoping.Api/DependencyInjection.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; using JdeScoping.Api.Hubs; -using JdeScoping.Core.Options; +using JdeScoping.Api.Options; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; diff --git a/NEW/src/JdeScoping.Core/Options/AuthOptions.cs b/NEW/src/JdeScoping.Api/Options/AuthOptions.cs similarity index 52% rename from NEW/src/JdeScoping.Core/Options/AuthOptions.cs rename to NEW/src/JdeScoping.Api/Options/AuthOptions.cs index af7d773..8e05484 100644 --- a/NEW/src/JdeScoping.Core/Options/AuthOptions.cs +++ b/NEW/src/JdeScoping.Api/Options/AuthOptions.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.Core.Options; +namespace JdeScoping.Api.Options; /// /// Authentication configuration options @@ -10,12 +10,6 @@ public class AuthOptions /// public const string SectionName = "Auth"; - /// - /// Enable fake authentication for development. - /// When true, any credentials are accepted. - /// - public bool UseFakeAuth { get; set; } = false; - /// /// Name of the authentication cookie. /// @@ -25,10 +19,4 @@ public class AuthOptions /// Cookie expiration in minutes (default: 8 hours). /// public int CookieExpirationMinutes { get; set; } = 480; - - /// - /// Optional list of usernames that bypass group check. - /// Use sparingly for admin/testing purposes. - /// - public string[] AdminBypassUsers { get; set; } = []; } diff --git a/NEW/src/JdeScoping.Core/Options/DataSyncOptions.cs b/NEW/src/JdeScoping.Core/Options/DataSyncOptions.cs deleted file mode 100644 index 3932cb4..0000000 --- a/NEW/src/JdeScoping.Core/Options/DataSyncOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace JdeScoping.Core.Options; - -/// -/// Configuration options for data synchronization background jobs. -/// -public class DataSyncOptions -{ - /// - /// Configuration section name in appsettings.json. - /// - public const string SectionName = "DataSync"; - - /// - /// Cron schedule for mass (full) data refresh. Empty string disables the schedule. - /// - public string MassRefreshCronSchedule { get; set; } = "0 0 6 * * SAT"; - - /// - /// Cron schedule for daily incremental data refresh. Empty string disables the schedule. - /// - public string DailyRefreshCronSchedule { get; set; } = "0 0 4 * * *"; - - /// - /// Cron schedule for hourly data refresh. Empty string disables the schedule. - /// - public string HourlyRefreshCronSchedule { get; set; } = "0 0 * * * *"; - - /// - /// Maximum number of concurrent update operations. - /// - public int MaxConcurrentUpdates { get; set; } = 4; -} diff --git a/NEW/src/JdeScoping.Core/Options/ExcelExportOptions.cs b/NEW/src/JdeScoping.Core/Options/ExcelExportOptions.cs deleted file mode 100644 index b15831f..0000000 --- a/NEW/src/JdeScoping.Core/Options/ExcelExportOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace JdeScoping.Core.Options; - -/// -/// Configuration options for Excel export functionality. -/// -public class ExcelExportOptions -{ - /// - /// Configuration section name in appsettings.json. - /// - public const string SectionName = "ExcelExport"; - - /// - /// Directory for temporary Excel files. - /// - public string TempDirectory { get; set; } = "/tmp/lotfinder"; - - /// - /// Maximum number of rows per Excel sheet. - /// - public int MaxRowsPerSheet { get; set; } = 1048576; - - /// - /// Default date format for Excel cells. - /// - public string DefaultDateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss"; -} diff --git a/NEW/src/JdeScoping.Core/Options/SearchOptions.cs b/NEW/src/JdeScoping.Core/Options/SearchOptions.cs deleted file mode 100644 index 224263e..0000000 --- a/NEW/src/JdeScoping.Core/Options/SearchOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JdeScoping.Core.Options; - -public class SearchOptions -{ - public int MaxResultRows { get; set; } = 100000; - public int TimeoutSeconds { get; set; } = 300; - public int MaxConcurrentSearches { get; set; } = 5; -} diff --git a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs index ca64e7a..9218a87 100644 --- a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs +++ b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs @@ -1,7 +1,6 @@ using JdeScoping.Core.Interfaces; -using JdeScoping.Core.Options; using JdeScoping.DataAccess; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.FilterHandlers; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.QueryBuilders; diff --git a/NEW/src/JdeScoping.DataAccess/Configuration/DataAccessOptions.cs b/NEW/src/JdeScoping.DataAccess/Options/DataAccessOptions.cs similarity index 93% rename from NEW/src/JdeScoping.DataAccess/Configuration/DataAccessOptions.cs rename to NEW/src/JdeScoping.DataAccess/Options/DataAccessOptions.cs index 66c3857..39d3c02 100644 --- a/NEW/src/JdeScoping.DataAccess/Configuration/DataAccessOptions.cs +++ b/NEW/src/JdeScoping.DataAccess/Options/DataAccessOptions.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.DataAccess.Configuration; +namespace JdeScoping.DataAccess.Options; /// /// Configuration options for the data access layer. diff --git a/NEW/src/JdeScoping.DataAccess/Options/SearchOptions.cs b/NEW/src/JdeScoping.DataAccess/Options/SearchOptions.cs new file mode 100644 index 0000000..bf66979 --- /dev/null +++ b/NEW/src/JdeScoping.DataAccess/Options/SearchOptions.cs @@ -0,0 +1,27 @@ +namespace JdeScoping.DataAccess.Options; + +/// +/// Configuration options for search operations. +/// +public class SearchOptions +{ + /// + /// Configuration section name in appsettings.json. + /// + public const string SectionName = "Search"; + + /// + /// Maximum number of result rows to return. + /// + public int MaxResultRows { get; set; } = 100000; + + /// + /// Search query timeout in seconds. + /// + public int TimeoutSeconds { get; set; } = 300; + + /// + /// Maximum number of concurrent search operations. + /// + public int MaxConcurrentSearches { get; set; } = 5; +} diff --git a/NEW/src/JdeScoping.DataAccess/Configuration/SearchProcessingConfiguration.cs b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingConfiguration.cs similarity index 91% rename from NEW/src/JdeScoping.DataAccess/Configuration/SearchProcessingConfiguration.cs rename to NEW/src/JdeScoping.DataAccess/Options/SearchProcessingConfiguration.cs index d8aa8dc..98b983e 100644 --- a/NEW/src/JdeScoping.DataAccess/Configuration/SearchProcessingConfiguration.cs +++ b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingConfiguration.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.DataAccess.Configuration; +namespace JdeScoping.DataAccess.Options; /// /// Configuration options for search processing. diff --git a/NEW/src/JdeScoping.Core/Options/SearchProcessingOptions.cs b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingOptions.cs similarity index 91% rename from NEW/src/JdeScoping.Core/Options/SearchProcessingOptions.cs rename to NEW/src/JdeScoping.DataAccess/Options/SearchProcessingOptions.cs index 734916d..c3bd95a 100644 --- a/NEW/src/JdeScoping.Core/Options/SearchProcessingOptions.cs +++ b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingOptions.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.Core.Options; +namespace JdeScoping.DataAccess.Options; /// /// Configuration options for search processing background service. diff --git a/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs b/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs index 7dcc9b2..69dbf95 100644 --- a/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs +++ b/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs @@ -2,7 +2,7 @@ using System.Runtime.CompilerServices; using Dapper; using JdeScoping.Core.Models; using JdeScoping.Core.Models.Quality; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Queries; using Microsoft.Extensions.Logging; diff --git a/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs b/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs index b4bbf16..21bb460 100644 --- a/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs +++ b/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; using Dapper; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Interfaces; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs b/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs index db186ff..6881f2e 100644 --- a/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs +++ b/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs @@ -1,6 +1,6 @@ using System.Data; using JdeScoping.Core.Interfaces; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Exceptions; using JdeScoping.DataAccess.Interfaces; using Microsoft.Data.SqlClient; diff --git a/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs b/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs index a8c54de..6c3c9ae 100644 --- a/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs +++ b/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; using Dapper; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Models; using JdeScoping.DataAccess.Models.Results; diff --git a/NEW/src/JdeScoping.DataSync/DataSyncService.cs b/NEW/src/JdeScoping.DataSync/DataSyncService.cs index a28be2e..88f92ee 100644 --- a/NEW/src/JdeScoping.DataSync/DataSyncService.cs +++ b/NEW/src/JdeScoping.DataSync/DataSyncService.cs @@ -1,4 +1,4 @@ -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Telemetry; using Microsoft.Extensions.DependencyInjection; diff --git a/NEW/src/JdeScoping.DataSync/DependencyInjection.cs b/NEW/src/JdeScoping.DataSync/DependencyInjection.cs index cd2c8a4..244806b 100644 --- a/NEW/src/JdeScoping.DataSync/DependencyInjection.cs +++ b/NEW/src/JdeScoping.DataSync/DependencyInjection.cs @@ -4,7 +4,7 @@ using JdeScoping.Core.Models.Organization; using JdeScoping.Core.Models.Quality; using JdeScoping.Core.Models.WorkOrders; using JdeScoping.DataSync; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Configuration.MergeConfigurations; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Fetchers.Cms; diff --git a/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs b/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs index 568eba1..ac854f7 100644 --- a/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs +++ b/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs @@ -1,5 +1,5 @@ using JdeScoping.Core.Models.Enums; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; namespace JdeScoping.DataSync.Models; diff --git a/NEW/src/JdeScoping.DataSync/Configuration/DataSourceConfig.cs b/NEW/src/JdeScoping.DataSync/Options/DataSourceConfig.cs similarity index 94% rename from NEW/src/JdeScoping.DataSync/Configuration/DataSourceConfig.cs rename to NEW/src/JdeScoping.DataSync/Options/DataSourceConfig.cs index 10f67d1..1b10e26 100644 --- a/NEW/src/JdeScoping.DataSync/Configuration/DataSourceConfig.cs +++ b/NEW/src/JdeScoping.DataSync/Options/DataSourceConfig.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.DataSync.Configuration; +namespace JdeScoping.DataSync.Options; /// /// Configuration for a single data source table sync. diff --git a/NEW/src/JdeScoping.DataSync/Configuration/DataSyncOptions.cs b/NEW/src/JdeScoping.DataSync/Options/DataSyncOptions.cs similarity index 94% rename from NEW/src/JdeScoping.DataSync/Configuration/DataSyncOptions.cs rename to NEW/src/JdeScoping.DataSync/Options/DataSyncOptions.cs index 4556147..45dee4f 100644 --- a/NEW/src/JdeScoping.DataSync/Configuration/DataSyncOptions.cs +++ b/NEW/src/JdeScoping.DataSync/Options/DataSyncOptions.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace JdeScoping.DataSync.Configuration; +namespace JdeScoping.DataSync.Options; /// /// Configuration options for the data synchronization service. diff --git a/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs b/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs index 086feff..c1fcf91 100644 --- a/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs +++ b/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs @@ -1,7 +1,7 @@ using JdeScoping.Core.Models; using JdeScoping.Core.Models.Enums; using JdeScoping.Core.Models.Infrastructure; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Models; using Microsoft.Extensions.Logging; diff --git a/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs b/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs index 0467e8f..3ead9ee 100644 --- a/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs +++ b/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs @@ -1,4 +1,4 @@ -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Telemetry; using Microsoft.Extensions.DependencyInjection; diff --git a/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs b/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs index d8ca2a1..f2e056e 100644 --- a/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs +++ b/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs @@ -4,7 +4,7 @@ using JdeScoping.Core.Models; using JdeScoping.Core.Models.Enums; using JdeScoping.Core.Interfaces; using JdeScoping.DataAccess.Interfaces; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Models; using JdeScoping.DataSync.Telemetry; diff --git a/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs b/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs index d0b0128..93d2dcd 100644 --- a/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs +++ b/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs @@ -1,6 +1,6 @@ using JdeScoping.Core.Interfaces; using JdeScoping.ExcelIO; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Generators; using JdeScoping.ExcelIO.Helpers; using JdeScoping.ExcelIO.Parsing; diff --git a/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs b/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs index f58b51b..8f3bff6 100644 --- a/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs +++ b/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs @@ -2,7 +2,7 @@ using System.Reflection; using ClosedXML.Excel; using JdeScoping.Core.Interfaces; using JdeScoping.ExcelIO.Attributes; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Formatting; using JdeScoping.ExcelIO.Generators; using JdeScoping.ExcelIO.Models.Reporting; diff --git a/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs b/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs index 29d9024..2d1971d 100644 --- a/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs +++ b/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs @@ -1,5 +1,5 @@ using ClosedXML.Excel; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Formatting; using JdeScoping.ExcelIO.Models.Reporting; using Microsoft.Extensions.Options; diff --git a/NEW/src/JdeScoping.ExcelIO/Configuration/ExcelExportOptions.cs b/NEW/src/JdeScoping.ExcelIO/Options/ExcelExportOptions.cs similarity index 93% rename from NEW/src/JdeScoping.ExcelIO/Configuration/ExcelExportOptions.cs rename to NEW/src/JdeScoping.ExcelIO/Options/ExcelExportOptions.cs index 13b35c4..988ce6e 100644 --- a/NEW/src/JdeScoping.ExcelIO/Configuration/ExcelExportOptions.cs +++ b/NEW/src/JdeScoping.ExcelIO/Options/ExcelExportOptions.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.ExcelIO.Configuration; +namespace JdeScoping.ExcelIO.Options; /// /// Configuration options for Excel export functionality. diff --git a/NEW/src/JdeScoping.Host/Program.cs b/NEW/src/JdeScoping.Host/Program.cs index a30149b..6ebbe58 100644 --- a/NEW/src/JdeScoping.Host/Program.cs +++ b/NEW/src/JdeScoping.Host/Program.cs @@ -1,7 +1,9 @@ using JdeScoping.Api; using JdeScoping.Core.Interfaces; -using JdeScoping.Core.Options; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; +using JdeScoping.DataSync.Options; +using JdeScoping.ExcelIO.Options; +using JdeScoping.Infrastructure.Options; using JdeScoping.Database; using Microsoft.Extensions.Options; @@ -68,7 +70,6 @@ static void ValidateServices(IServiceProvider services) // Validate Options classes are bound _ = provider.GetRequiredService>(); _ = provider.GetRequiredService>(); - _ = provider.GetRequiredService>(); _ = provider.GetRequiredService>(); _ = provider.GetRequiredService>(); _ = provider.GetRequiredService>(); diff --git a/NEW/src/JdeScoping.Host/appsettings.json b/NEW/src/JdeScoping.Host/appsettings.json index f17ad99..91d5cd7 100644 --- a/NEW/src/JdeScoping.Host/appsettings.json +++ b/NEW/src/JdeScoping.Host/appsettings.json @@ -112,16 +112,16 @@ ] }, "Auth": { - "UseFakeAuth": false, "CookieName": "ScopingTool.Auth", - "CookieExpirationMinutes": 480, - "AdminBypassUsers": [] + "CookieExpirationMinutes": 480 }, "Ldap": { "ServerUrls": ["ldap.corp.example.com"], "GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com", "SearchBase": "DC=corp,DC=example,DC=com", - "ConnectionTimeoutSeconds": 30 + "ConnectionTimeoutSeconds": 30, + "UseFakeAuth": false, + "AdminBypassUsers": [] }, "ExcelExport": { "TempDirectory": "/tmp/lotfinder", diff --git a/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs b/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs index 84f6890..e442ae9 100644 --- a/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs +++ b/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs @@ -2,7 +2,7 @@ using System.DirectoryServices.Protocols; using System.Net; using JdeScoping.Core.Interfaces; using JdeScoping.Core.Models; -using JdeScoping.Core.Options; +using JdeScoping.Infrastructure.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,16 +16,13 @@ public sealed class LdapAuthService : IAuthService private const string LdapLookupFormat = "(sAMAccountName={0})"; private readonly LdapOptions _options; - private readonly AuthOptions _authOptions; private readonly ILogger _logger; public LdapAuthService( IOptions options, - IOptions authOptions, ILogger logger) { _options = options.Value; - _authOptions = authOptions.Value; _logger = logger; } @@ -41,7 +38,7 @@ public sealed class LdapAuthService : IAuthService } // Check if user is in admin bypass list - var isAdminBypass = _authOptions.AdminBypassUsers + var isAdminBypass = _options.AdminBypassUsers .Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase)); // Try each configured LDAP server diff --git a/NEW/src/JdeScoping.Core/Options/DataSourceOptions.cs b/NEW/src/JdeScoping.Infrastructure/Options/DataSourceOptions.cs similarity index 90% rename from NEW/src/JdeScoping.Core/Options/DataSourceOptions.cs rename to NEW/src/JdeScoping.Infrastructure/Options/DataSourceOptions.cs index 71c7ce1..3e39b30 100644 --- a/NEW/src/JdeScoping.Core/Options/DataSourceOptions.cs +++ b/NEW/src/JdeScoping.Infrastructure/Options/DataSourceOptions.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.Core.Options; +namespace JdeScoping.Infrastructure.Options; /// /// Configuration options for data source selection (Oracle vs file-based). diff --git a/NEW/src/JdeScoping.Core/Options/LdapOptions.cs b/NEW/src/JdeScoping.Infrastructure/Options/LdapOptions.cs similarity index 67% rename from NEW/src/JdeScoping.Core/Options/LdapOptions.cs rename to NEW/src/JdeScoping.Infrastructure/Options/LdapOptions.cs index 8c0ab7e..0fff2b6 100644 --- a/NEW/src/JdeScoping.Core/Options/LdapOptions.cs +++ b/NEW/src/JdeScoping.Infrastructure/Options/LdapOptions.cs @@ -1,4 +1,4 @@ -namespace JdeScoping.Core.Options; +namespace JdeScoping.Infrastructure.Options; /// /// LDAP configuration options for authentication @@ -32,4 +32,16 @@ public class LdapOptions /// Connection timeout in seconds. /// public int ConnectionTimeoutSeconds { get; set; } = 30; + + /// + /// Enable fake authentication for development. + /// When true, any credentials are accepted. + /// + public bool UseFakeAuth { get; set; } = false; + + /// + /// Optional list of usernames that bypass group check. + /// Use sparingly for admin/testing purposes. + /// + public string[] AdminBypassUsers { get; set; } = []; } diff --git a/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs b/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs index 701e78d..7c67719 100644 --- a/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs +++ b/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs @@ -3,7 +3,7 @@ using System.Text.Json; using JdeScoping.Core.Interfaces; using JdeScoping.Core.Models; using JdeScoping.Core.Models.Quality; -using JdeScoping.Core.Options; +using JdeScoping.Infrastructure.Options; using Microsoft.Extensions.Options; namespace JdeScoping.Infrastructure.Sources.Cms; diff --git a/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs b/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs index da216a5..77bb346 100644 --- a/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs +++ b/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs @@ -5,7 +5,7 @@ using JdeScoping.Core.Models; using JdeScoping.Core.Models.Inventory; using JdeScoping.Core.Models.Organization; using JdeScoping.Core.Models.WorkOrders; -using JdeScoping.Core.Options; +using JdeScoping.Infrastructure.Options; using Microsoft.Extensions.Options; namespace JdeScoping.Infrastructure.Sources.Jde; diff --git a/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs b/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs index b410ae2..d21eb10 100644 --- a/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs +++ b/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs @@ -1,4 +1,4 @@ -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Exceptions; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Repositories; @@ -24,7 +24,7 @@ public class CmsRepositoryTests { _connectionFactory = Substitute.For(); _logger = Substitute.For>(); - _options = Options.Create(new DataAccessOptions + _options = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions { DefaultTimeoutSeconds = 30, MisDataTimeoutSeconds = 60000 @@ -197,7 +197,7 @@ public class CmsRepositoryTests public void Constructor_UsesMisDataTimeout() { // Arrange - var customOptions = Options.Create(new DataAccessOptions + var customOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions { MisDataTimeoutSeconds = 999999 }); @@ -214,7 +214,7 @@ public class CmsRepositoryTests public void Constructor_DefaultMisDataTimeout_Is60000Seconds() { // Arrange - var defaultOptions = Options.Create(new DataAccessOptions()); + var defaultOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions()); // Act var repository = new CmsRepository(_connectionFactory, _logger, defaultOptions); diff --git a/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs b/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs index 1364439..da1bd82 100644 --- a/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs +++ b/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs @@ -1,4 +1,4 @@ -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Exceptions; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Repositories; @@ -24,7 +24,7 @@ public class JdeRepositoryTests { _connectionFactory = Substitute.For(); _logger = Substitute.For>(); - _options = Options.Create(new DataAccessOptions + _options = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions { DefaultTimeoutSeconds = 30, LotUsageTimeoutSeconds = 60, @@ -637,7 +637,7 @@ public class JdeRepositoryTests public void Constructor_UsesConfiguredSchemas() { // Arrange - var customOptions = Options.Create(new DataAccessOptions + var customOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions { ProductionSchema = "CUSTOM_PROD", ArchiveSchema = "CUSTOM_ARC", @@ -656,7 +656,7 @@ public class JdeRepositoryTests public void Constructor_UsesConfiguredTimeouts() { // Arrange - var customOptions = Options.Create(new DataAccessOptions + var customOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions { DefaultTimeoutSeconds = 120, LotUsageTimeoutSeconds = 999999 diff --git a/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs b/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs index ffd298c..955d24e 100644 --- a/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs +++ b/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs @@ -3,7 +3,7 @@ using JdeScoping.Core.Models.Enums; using JdeScoping.Core.Models.Inventory; using JdeScoping.Core.Models.Search; using JdeScoping.Core.ViewModels; -using JdeScoping.DataAccess.Configuration; +using JdeScoping.DataAccess.Options; using JdeScoping.DataAccess.Exceptions; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Repositories; @@ -29,7 +29,7 @@ public class LotFinderRepositoryTests { _connectionFactory = Substitute.For(); _logger = Substitute.For>(); - _options = Options.Create(new DataAccessOptions + _options = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions { DefaultTimeoutSeconds = 30, RebuildIndexTimeoutSeconds = 60 diff --git a/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs b/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs index cb04bc1..06e3d19 100644 --- a/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs +++ b/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs @@ -4,7 +4,7 @@ using System.Runtime.CompilerServices; using JdeScoping.Core.Interfaces; using JdeScoping.Core.Models.Enums; using JdeScoping.DataAccess.Interfaces; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.IntegrationTests.Infrastructure; using JdeScoping.DataSync.Models; @@ -316,7 +316,7 @@ public class TableSyncOperationTests : IAsyncLifetime return new MassInsertResult(records.Count, TimeSpan.Zero, true); }); - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { BatchSize = 1000, BulkCopyBatchSize = 100 diff --git a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs index fcdaa6f..827aa2b 100644 --- a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs +++ b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs @@ -1,5 +1,5 @@ using System.Diagnostics.Metrics; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Telemetry; using Microsoft.Extensions.DependencyInjection; @@ -22,7 +22,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenDisabled_ExitsImmediately() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = false }); @@ -56,7 +56,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenEnabled_StartsAndCanBeStopped() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(100) @@ -106,7 +106,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_GracefulShutdown_CompletesCleanly() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromSeconds(10) // Long interval @@ -152,7 +152,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_AtStartup_CallsCloseOpenUpdateEntries() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) @@ -199,7 +199,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenCloseOpenEntriesFindsEntries_LogsAndContinues() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) @@ -248,7 +248,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenCloseOpenEntriesThrows_ContinuesStarting() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) @@ -301,7 +301,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_CallsOrchestratorForParallelExecution() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50), @@ -349,7 +349,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenOrchestratorThrows_ContinuesNextCycle() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) @@ -404,7 +404,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenCancelled_StopsGracefully() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromSeconds(10) @@ -462,7 +462,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_PassesCancellationTokenToOrchestrator() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) @@ -510,7 +510,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenCancelledDuringDelay_ExitsCleanly() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMinutes(5) // Long delay @@ -562,7 +562,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_UsesNewScopePerCycle() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) @@ -610,7 +610,7 @@ public class DataSyncServiceTests public async Task ExecuteAsync_WhenSyncFails_ContinuesRunning() { // Arrange - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { Enabled = true, CheckInterval = TimeSpan.FromMilliseconds(50) diff --git a/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs index a5ba12f..2dce024 100644 --- a/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs +++ b/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs @@ -1,7 +1,7 @@ using JdeScoping.Core.Models; using JdeScoping.Core.Models.Enums; using JdeScoping.Core.Models.Infrastructure; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Services; using Microsoft.Extensions.Logging.Abstractions; @@ -23,7 +23,7 @@ public class ScheduleCheckerTests public ScheduleCheckerTests() { _repository = Substitute.For(); - _options = Options.Create(new DataSyncOptions + _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { LookbackMultiplier = 3, DataSources = [] diff --git a/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs index a6a9539..9ef5481 100644 --- a/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs +++ b/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs @@ -1,6 +1,6 @@ using System.Diagnostics.Metrics; using JdeScoping.Core.Models.Enums; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Models; using JdeScoping.DataSync.Services; @@ -27,7 +27,7 @@ public class SyncOrchestratorTests public SyncOrchestratorTests() { _scheduleChecker = Substitute.For(); - _options = Options.Create(new DataSyncOptions + _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { MaxDegreeOfParallelism = 4 }); @@ -176,7 +176,7 @@ public class SyncOrchestratorTests public async Task ExecutePendingSyncsAsync_RespectsMaxDegreeOfParallelism() { // Arrange: Create 10 tasks but limit parallelism to 2 - var options = Options.Create(new DataSyncOptions + var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { MaxDegreeOfParallelism = 2 }); diff --git a/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs index 33ee20a..7c12ea4 100644 --- a/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs +++ b/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs @@ -5,7 +5,7 @@ using JdeScoping.Core.Interfaces; using JdeScoping.Core.Models; using JdeScoping.Core.Models.Enums; using JdeScoping.DataAccess.Interfaces; -using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Models; using JdeScoping.DataSync.Services; @@ -40,7 +40,7 @@ public class TableSyncOperationTests _bulkMergeHelper = Substitute.For(); _configRegistry = Substitute.For(); - _options = Options.Create(new DataSyncOptions + _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { BatchSize = 1000, BulkCopyBatchSize = 100 diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs index de1702a..39b4d03 100644 --- a/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs +++ b/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs @@ -1,5 +1,5 @@ using ClosedXML.Excel; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Generators; using JdeScoping.ExcelIO.Helpers; using JdeScoping.ExcelIO.Models.Reporting; @@ -16,7 +16,7 @@ public class CriteriaSheetGeneratorTests public CriteriaSheetGeneratorTests() { - _options = Options.Create(new ExcelExportOptions + _options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions { CriteriaSheetPassword = "TestPassword" }); diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs index e59e712..f4e148b 100644 --- a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs +++ b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs @@ -1,5 +1,5 @@ using ClosedXML.Excel; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Generators; using JdeScoping.ExcelIO.Helpers; using JdeScoping.ExcelIO.Models.Reporting; @@ -23,7 +23,7 @@ public class ExcelExportIntegrationTests public ExcelExportIntegrationTests() { _logger = Substitute.For>(); - _options = Options.Create(new ExcelExportOptions + _options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions { CriteriaSheetPassword = "TestCriteriaPass", DataSheetPassword = "TestDataPass" diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs index 9e12346..15fc283 100644 --- a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs +++ b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs @@ -1,5 +1,5 @@ using ClosedXML.Excel; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Generators; using JdeScoping.ExcelIO.Helpers; using JdeScoping.ExcelIO.Models.Reporting; @@ -20,7 +20,7 @@ public class ExcelExportServiceTests public ExcelExportServiceTests() { _logger = Substitute.For>(); - _options = Options.Create(new ExcelExportOptions + _options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions { CriteriaSheetPassword = "TestCriteriaPass", DataSheetPassword = "TestDataPass" diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs index a0f466e..368abb0 100644 --- a/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs +++ b/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs @@ -1,5 +1,5 @@ using ClosedXML.Excel; -using JdeScoping.ExcelIO.Configuration; +using JdeScoping.ExcelIO.Options; using JdeScoping.ExcelIO.Formatting; using JdeScoping.ExcelIO.Generators; using JdeScoping.ExcelIO.Helpers; @@ -24,7 +24,7 @@ public class LegacyComparisonTests public LegacyComparisonTests() { var logger = Substitute.For>(); - var options = Options.Create(new ExcelExportOptions + var options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions { CriteriaSheetPassword = "JDE_SCOPING_TOOL_PASS", DataSheetPassword = "JDESCOPINGTOOL" diff --git a/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs b/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs index 73cae2f..867a8fd 100644 --- a/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs +++ b/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs @@ -1,4 +1,4 @@ -using JdeScoping.Core.Options; +using JdeScoping.Infrastructure.Options; using JdeScoping.Infrastructure.Auth; using JdeScoping.Infrastructure.Tests.Helpers; using Microsoft.Extensions.Logging; @@ -52,19 +52,16 @@ public class LdapIntegrationTests { // Arrange var testUser = MockLdapServer.ValidGroupMemberUser; - var ldapOptions = Options.Create(new LdapOptions + var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = MockLdapServer.FakeServerUrls, GroupDn = MockLdapServer.TestGroupDn, SearchBase = MockLdapServer.TestSearchBase, - ConnectionTimeoutSeconds = 1 // Fast timeout for test - }); - var authOptions = Options.Create(new AuthOptions - { + ConnectionTimeoutSeconds = 1, // Fast timeout for test AdminBypassUsers = [] }); - var service = new LdapAuthService(ldapOptions, authOptions, _logger); + var service = new LdapAuthService(ldapOptions, _logger); // Act var result = await service.AuthenticateAsync(testUser.Username, testUser.Password); @@ -105,19 +102,16 @@ public class LdapIntegrationTests { // Arrange var testUser = MockLdapServer.ValidNotInGroupUser; - var ldapOptions = Options.Create(new LdapOptions + var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = MockLdapServer.FakeServerUrls, GroupDn = MockLdapServer.TestGroupDn, SearchBase = MockLdapServer.TestSearchBase, - ConnectionTimeoutSeconds = 1 - }); - var authOptions = Options.Create(new AuthOptions - { + ConnectionTimeoutSeconds = 1, AdminBypassUsers = [] }); - var service = new LdapAuthService(ldapOptions, authOptions, _logger); + var service = new LdapAuthService(ldapOptions, _logger); // Act var result = await service.AuthenticateAsync(testUser.Username, testUser.Password); @@ -152,19 +146,16 @@ public class LdapIntegrationTests { // Arrange var testUser = MockLdapServer.InvalidCredentialsUser; - var ldapOptions = Options.Create(new LdapOptions + var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = MockLdapServer.FakeServerUrls, GroupDn = MockLdapServer.TestGroupDn, SearchBase = MockLdapServer.TestSearchBase, - ConnectionTimeoutSeconds = 1 - }); - var authOptions = Options.Create(new AuthOptions - { + ConnectionTimeoutSeconds = 1, AdminBypassUsers = [] }); - var service = new LdapAuthService(ldapOptions, authOptions, _logger); + var service = new LdapAuthService(ldapOptions, _logger); // Act var result = await service.AuthenticateAsync(testUser.Username, testUser.Password); @@ -205,19 +196,16 @@ public class LdapIntegrationTests public async Task AuthenticateAsync_AllServersFail_ReturnsConnectionError() { // Arrange - var ldapOptions = Options.Create(new LdapOptions + var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = MockLdapServer.FakeServerUrls, // 3 fake servers that will all fail GroupDn = MockLdapServer.TestGroupDn, SearchBase = MockLdapServer.TestSearchBase, - ConnectionTimeoutSeconds = 1 // Fast timeout for test - }); - var authOptions = Options.Create(new AuthOptions - { + ConnectionTimeoutSeconds = 1, // Fast timeout for test AdminBypassUsers = [] }); - var service = new LdapAuthService(ldapOptions, authOptions, _logger); + var service = new LdapAuthService(ldapOptions, _logger); // Act var result = await service.AuthenticateAsync("anyuser", "anypassword"); diff --git a/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs b/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs index 39d46bc..b46fef4 100644 --- a/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs +++ b/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs @@ -1,5 +1,5 @@ -using JdeScoping.Core.Options; using JdeScoping.Infrastructure.Auth; +using JdeScoping.Infrastructure.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; @@ -10,20 +10,16 @@ namespace JdeScoping.Infrastructure.Tests.Unit; public class LdapAuthServiceTests { private readonly IOptions _ldapOptions; - private readonly IOptions _authOptions; private readonly ILogger _logger; public LdapAuthServiceTests() { - _ldapOptions = Options.Create(new LdapOptions + _ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = ["ldap.test.com"], GroupDn = "CN=TestGroup,DC=test,DC=com", SearchBase = "DC=test,DC=com", - ConnectionTimeoutSeconds = 5 - }); - _authOptions = Options.Create(new AuthOptions - { + ConnectionTimeoutSeconds = 5, AdminBypassUsers = [] }); _logger = Substitute.For>(); @@ -33,7 +29,7 @@ public class LdapAuthServiceTests public async Task AuthenticateAsync_EmptyUsername_ReturnsFailure() { // Arrange - var service = new LdapAuthService(_ldapOptions, _authOptions, _logger); + var service = new LdapAuthService(_ldapOptions, _logger); // Act var result = await service.AuthenticateAsync("", "password"); @@ -47,7 +43,7 @@ public class LdapAuthServiceTests public async Task AuthenticateAsync_EmptyPassword_ReturnsFailure() { // Arrange - var service = new LdapAuthService(_ldapOptions, _authOptions, _logger); + var service = new LdapAuthService(_ldapOptions, _logger); // Act var result = await service.AuthenticateAsync("user", ""); @@ -61,13 +57,14 @@ public class LdapAuthServiceTests public async Task AuthenticateAsync_NoServersConfigured_ReturnsConnectionError() { // Arrange - var emptyServerOptions = Options.Create(new LdapOptions + var emptyServerOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = [], GroupDn = "CN=TestGroup,DC=test,DC=com", - SearchBase = "DC=test,DC=com" + SearchBase = "DC=test,DC=com", + AdminBypassUsers = [] }); - var service = new LdapAuthService(emptyServerOptions, _authOptions, _logger); + var service = new LdapAuthService(emptyServerOptions, _logger); // Act var result = await service.AuthenticateAsync("user", "password"); @@ -81,7 +78,7 @@ public class LdapAuthServiceTests public void GetUserInfoAsync_ThrowsNotSupportedException() { // Arrange - var service = new LdapAuthService(_ldapOptions, _authOptions, _logger); + var service = new LdapAuthService(_ldapOptions, _logger); // Act & Assert Should.Throw(() => service.GetUserInfoAsync("user").GetAwaiter().GetResult()); @@ -93,11 +90,15 @@ public class LdapAuthServiceTests // Arrange // Note: We can't fully test admin bypass without a real LDAP server since bind still happens. // This test verifies the configuration is recognized by checking that bypass users are configured. - var authOptionsWithBypass = Options.Create(new AuthOptions + var ldapOptionsWithBypass = Microsoft.Extensions.Options.Options.Create(new LdapOptions { + ServerUrls = ["ldap.test.com"], + GroupDn = "CN=TestGroup,DC=test,DC=com", + SearchBase = "DC=test,DC=com", + ConnectionTimeoutSeconds = 5, AdminBypassUsers = ["bypassuser", "adminuser"] }); - var service = new LdapAuthService(_ldapOptions, authOptionsWithBypass, _logger); + var service = new LdapAuthService(ldapOptionsWithBypass, _logger); // Act - attempt to authenticate the bypass user (will fail LDAP connection, but config is exercised) var result = await service.AuthenticateAsync("bypassuser", "anypassword"); @@ -113,14 +114,15 @@ public class LdapAuthServiceTests public async Task AuthenticateAsync_MultipleServersConfigured_TriesEachUntilAllFail() { // Arrange - var multiServerOptions = Options.Create(new LdapOptions + var multiServerOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions { ServerUrls = ["ldap1.test.com", "ldap2.test.com", "ldap3.test.com"], GroupDn = "CN=TestGroup,DC=test,DC=com", SearchBase = "DC=test,DC=com", - ConnectionTimeoutSeconds = 1 // Fast timeout for test + ConnectionTimeoutSeconds = 1, // Fast timeout for test + AdminBypassUsers = [] }); - var service = new LdapAuthService(multiServerOptions, _authOptions, _logger); + var service = new LdapAuthService(multiServerOptions, _logger); // Act var result = await service.AuthenticateAsync("testuser", "testpassword"); diff --git a/PLANS/2026-01-03-encrypted-login-design.md b/PLANS/2026-01-03-encrypted-login-design.md new file mode 100644 index 0000000..baa1739 --- /dev/null +++ b/PLANS/2026-01-03-encrypted-login-design.md @@ -0,0 +1,134 @@ +# Encrypted Login Design + +## Overview + +Consolidate login models into Core project and implement RSA-OAEP encryption for login credentials between Blazor WASM client and API. + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Encryption algorithm | RSA-OAEP (SHA-256) | Simple direct encryption, suitable for small payloads | +| Key size | 2048-bit | Industry standard, ~20 year security horizon | +| Key distribution | Fetch at runtime | Allows key rotation without client redeployment | +| Key storage | Auto-generate, persist to file | Self-bootstrapping, no manual key management | +| Key expiration | None | Internal enterprise app, manual rotation if needed | + +## New Models (Core) + +All in `JdeScoping.Core/Models/Auth/`: + +```csharp +// LoginModel.cs - shared form/request model +public class LoginModel +{ + [Required(ErrorMessage = "Username is required")] + public string Username { get; set; } = string.Empty; + + [Required(ErrorMessage = "Password is required")] + public string Password { get; set; } = string.Empty; +} + +// LoginResultModel.cs - API response (unencrypted) +public record LoginResultModel( + bool Success, + string? ErrorMessage, + UserInfo? User); + +// EncryptedLoginRequest.cs - encrypted payload wrapper +public record EncryptedLoginRequest(string EncryptedData); + +// PublicKeyResponse.cs - public key endpoint response +public record PublicKeyResponse(string PublicKeyPem); +``` + +## RSA Key Service (Infrastructure) + +**Interface:** `JdeScoping.Core/Interfaces/IRsaKeyService.cs` +```csharp +public interface IRsaKeyService +{ + string GetPublicKeyPem(); + byte[] Decrypt(byte[] ciphertext); +} +``` + +**Implementation:** `JdeScoping.Infrastructure/Security/RsaKeyService.cs` +- Auto-generates 2048-bit RSA key on first startup if not exists +- Persists private key to configurable file path +- Exports public key as PEM format +- Decrypts using RSA-OAEP with SHA-256 padding + +## API Changes + +**New endpoint:** +``` +GET /api/auth/public-key → PublicKeyResponse +``` + +**Modified endpoint:** +``` +POST /api/auth/login +Body: EncryptedLoginRequest { EncryptedData: base64 } +Response: LoginResultModel +``` + +Flow: +1. Receive base64-encoded encrypted data +2. Decode and decrypt with RSA private key +3. Deserialize JSON to LoginModel +4. Authenticate via IAuthService +5. Return LoginResultModel (unencrypted) + +## Client Changes + +**New service:** `ICryptoService` / `CryptoService` +- Uses `Blazor.SubtleCrypto` NuGet package +- Fetches and caches server public key +- Encrypts LoginModel JSON with RSA-OAEP +- Returns base64-encoded ciphertext + +**Modified AuthService:** +- Injects ICryptoService +- Encrypts LoginModel before sending +- Sends EncryptedLoginRequest to API + +## Files to Delete + +- `JdeScoping.Api/Models/LoginRequest.cs` +- `JdeScoping.Client/Models/LoginModel.cs` + +## Dependencies + +**JdeScoping.Client.csproj:** +```xml + +``` + +## DI Registration + +**API/Host:** +```csharp +services.AddSingleton(sp => + new RsaKeyService(Path.Combine(appDataPath, "rsa-key.bin"))); +``` + +**Client:** +```csharp +services.AddScoped(); +``` + +## Test Coverage + +### RsaKeyServiceTests (Api.Tests) +- GetPublicKeyPem returns valid PEM format +- Decrypt with valid ciphertext returns plaintext +- Constructor loads existing key from file +- Constructor generates and persists new key if missing + +### CryptoServiceTests (Client.Tests) +- EncryptLoginAsync returns valid base64 +- EncryptLoginAsync caches public key (single HTTP call) + +### Integration (optional) +- Full roundtrip: encrypt on client → decrypt on API → authenticate diff --git a/PLANS/2026-01-03-encrypted-login-implementation.md b/PLANS/2026-01-03-encrypted-login-implementation.md new file mode 100644 index 0000000..7b0fe19 --- /dev/null +++ b/PLANS/2026-01-03-encrypted-login-implementation.md @@ -0,0 +1,1384 @@ +# Encrypted Login Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Consolidate login models into Core and implement RSA-OAEP encryption for login credentials between Blazor WASM client and API. + +**Architecture:** Client fetches server's RSA public key at runtime, encrypts login credentials with RSA-OAEP, sends encrypted payload to API. Server decrypts with private key (auto-generated on first startup, persisted to file). Shared models in Core eliminate duplication. + +**Tech Stack:** .NET 10, RSA-OAEP (SHA-256), Blazor.SubtleCrypto (Web Crypto API wrapper), xUnit, NSubstitute, Shouldly + +--- + +## Task 1: Add Shared Auth Models to Core + +**Files:** +- Create: `NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs` +- Create: `NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs` +- Create: `NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs` +- Create: `NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs` + +**Step 1: Create Auth folder and LoginModel** + +```csharp +// NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs +using System.ComponentModel.DataAnnotations; + +namespace JdeScoping.Core.Models.Auth; + +/// +/// Login credentials model shared by Client and API. +/// +public class LoginModel +{ + [Required(ErrorMessage = "Username is required")] + public string Username { get; set; } = string.Empty; + + [Required(ErrorMessage = "Password is required")] + public string Password { get; set; } = string.Empty; +} +``` + +**Step 2: Create LoginResultModel** + +```csharp +// NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs +namespace JdeScoping.Core.Models.Auth; + +/// +/// Result returned from login API endpoint. +/// +/// Whether authentication succeeded +/// Error message if failed +/// User info if successful +public record LoginResultModel( + bool Success, + string? ErrorMessage, + UserInfo? User); +``` + +**Step 3: Create EncryptedLoginRequest** + +```csharp +// NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs +namespace JdeScoping.Core.Models.Auth; + +/// +/// Encrypted login payload sent from client to API. +/// +/// Base64-encoded RSA-encrypted JSON of LoginModel +public record EncryptedLoginRequest(string EncryptedData); +``` + +**Step 4: Create PublicKeyResponse** + +```csharp +// NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs +namespace JdeScoping.Core.Models.Auth; + +/// +/// Server's RSA public key for client-side encryption. +/// +/// PEM-encoded RSA public key +public record PublicKeyResponse(string PublicKeyPem); +``` + +**Step 5: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add NEW/src/JdeScoping.Core/Models/Auth/ +git commit -m "feat(core): add shared auth models for encrypted login" +``` + +--- + +## Task 2: Add IRsaKeyService Interface to Core + +**Files:** +- Create: `NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs` + +**Step 1: Create the interface** + +```csharp +// NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs +namespace JdeScoping.Core.Interfaces; + +/// +/// RSA key management for login encryption. +/// +public interface IRsaKeyService +{ + /// + /// Gets the server's public key in PEM format. + /// + string GetPublicKeyPem(); + + /// + /// Decrypts RSA-OAEP encrypted data. + /// + /// Encrypted bytes + /// Decrypted plaintext bytes + byte[] Decrypt(byte[] ciphertext); +} +``` + +**Step 2: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs +git commit -m "feat(core): add IRsaKeyService interface" +``` + +--- + +## Task 3: Implement RsaKeyService with Tests (TDD) + +**Files:** +- Create: `NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs` +- Create: `NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs` + +**Step 1: Write failing test for GetPublicKeyPem** + +```csharp +// NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs +using JdeScoping.Core.Interfaces; +using JdeScoping.Infrastructure.Security; +using Shouldly; + +namespace JdeScoping.Infrastructure.Tests.Security; + +public class RsaKeyServiceTests : IDisposable +{ + private readonly string _testKeyPath; + + public RsaKeyServiceTests() + { + _testKeyPath = Path.Combine(Path.GetTempPath(), $"test-rsa-key-{Guid.NewGuid()}.bin"); + } + + public void Dispose() + { + if (File.Exists(_testKeyPath)) + File.Delete(_testKeyPath); + } + + [Fact] + public void GetPublicKeyPem_ReturnsValidPemFormat() + { + // Arrange + var service = new RsaKeyService(_testKeyPath); + + // Act + var pem = service.GetPublicKeyPem(); + + // Assert + pem.ShouldStartWith("-----BEGIN PUBLIC KEY-----"); + pem.ShouldEndWith("-----END PUBLIC KEY-----"); + } + + [Fact] + public void Constructor_WhenKeyFileMissing_GeneratesAndPersistsKey() + { + // Arrange - ensure file doesn't exist + File.Exists(_testKeyPath).ShouldBeFalse(); + + // Act + _ = new RsaKeyService(_testKeyPath); + + // Assert + File.Exists(_testKeyPath).ShouldBeTrue(); + new FileInfo(_testKeyPath).Length.ShouldBeGreaterThan(0); + } + + [Fact] + public void Constructor_WhenKeyFileExists_LoadsSameKey() + { + // Arrange - create service to generate key + var service1 = new RsaKeyService(_testKeyPath); + var publicKey1 = service1.GetPublicKeyPem(); + + // Act - create new service instance + var service2 = new RsaKeyService(_testKeyPath); + var publicKey2 = service2.GetPublicKeyPem(); + + // Assert - same key loaded + publicKey2.ShouldBe(publicKey1); + } + + [Fact] + public void Decrypt_WithValidCiphertext_ReturnsPlaintext() + { + // Arrange + var service = new RsaKeyService(_testKeyPath); + var plaintext = "Hello, World!"u8.ToArray(); + + // Encrypt using public key (simulating what client does) + using var rsa = System.Security.Cryptography.RSA.Create(); + rsa.ImportFromPem(service.GetPublicKeyPem()); + var ciphertext = rsa.Encrypt(plaintext, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256); + + // Act + var decrypted = service.Decrypt(ciphertext); + + // Assert + decrypted.ShouldBe(plaintext); + } + + [Fact] + public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException() + { + // Arrange + var service = new RsaKeyService(_testKeyPath); + var invalidCiphertext = new byte[] { 1, 2, 3, 4, 5 }; + + // Act & Assert + Should.Throw( + () => service.Decrypt(invalidCiphertext)); + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal` +Expected: FAIL - RsaKeyService class not found + +**Step 3: Implement RsaKeyService** + +```csharp +// NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs +using System.Security.Cryptography; +using JdeScoping.Core.Interfaces; + +namespace JdeScoping.Infrastructure.Security; + +/// +/// RSA key service that auto-generates and persists keys. +/// +public class RsaKeyService : IRsaKeyService, IDisposable +{ + private readonly RSA _rsa; + + /// + /// Creates a new RSA key service. + /// + /// Path to persist the private key + public RsaKeyService(string keyFilePath) + { + _rsa = RSA.Create(2048); + + if (File.Exists(keyFilePath)) + { + var keyBytes = File.ReadAllBytes(keyFilePath); + _rsa.ImportRSAPrivateKey(keyBytes, out _); + } + else + { + var privateKey = _rsa.ExportRSAPrivateKey(); + var directory = Path.GetDirectoryName(keyFilePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + File.WriteAllBytes(keyFilePath, privateKey); + } + } + + /// + public string GetPublicKeyPem() + => _rsa.ExportSubjectPublicKeyInfoPem(); + + /// + public byte[] Decrypt(byte[] ciphertext) + => _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256); + + public void Dispose() + { + _rsa.Dispose(); + GC.SuppressFinalize(this); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal` +Expected: All 5 tests pass + +**Step 5: Commit** + +```bash +git add NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs +git add NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs +git commit -m "feat(infrastructure): implement RsaKeyService with tests" +``` + +--- + +## Task 4: Add RsaKeyOptions and DI Registration + +**Files:** +- Create: `NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs` +- Modify: `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs` + +**Step 1: Create RsaKeyOptions** + +```csharp +// NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs +namespace JdeScoping.Core.Options; + +/// +/// Configuration options for RSA key service. +/// +public class RsaKeyOptions +{ + public const string SectionName = "RsaKey"; + + /// + /// Path to store the RSA private key file. + /// Defaults to "data/rsa-key.bin" relative to app directory. + /// + public string KeyFilePath { get; set; } = "data/rsa-key.bin"; +} +``` + +**Step 2: Update Infrastructure DependencyInjection** + +Add to `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs` after existing using statements: + +```csharp +using JdeScoping.Infrastructure.Security; +``` + +Add inside `AddInfrastructure` method, after the auth service registration (around line 60): + +```csharp + // Register RSA key service for login encryption + services.Configure( + configuration.GetSection(RsaKeyOptions.SectionName)); + + var rsaKeyOptions = configuration + .GetSection(RsaKeyOptions.SectionName) + .Get() ?? new RsaKeyOptions(); + + var keyPath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath) + ? rsaKeyOptions.KeyFilePath + : Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath); + + services.AddSingleton(new RsaKeyService(keyPath)); +``` + +**Step 3: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj` +Expected: Build succeeded + +**Step 4: Commit** + +```bash +git add NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs +git add NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs +git commit -m "feat(infrastructure): register RsaKeyService in DI" +``` + +--- + +## Task 5: Update AuthController to Use Encrypted Login + +**Files:** +- Modify: `NEW/src/JdeScoping.Api/Controllers/AuthController.cs` +- Delete: `NEW/src/JdeScoping.Api/Models/LoginRequest.cs` + +**Step 1: Update AuthController imports and constructor** + +Replace the using statements at the top of `AuthController.cs`: + +```csharp +using System.Security.Claims; +using System.Text.Json; +using JdeScoping.Api.Extensions; +using JdeScoping.Core.Interfaces; +using JdeScoping.Core.Models; +using JdeScoping.Core.Models.Auth; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +``` + +Update the constructor to inject IRsaKeyService: + +```csharp +public class AuthController : ApiControllerBase +{ + private readonly IAuthService _authService; + private readonly IRsaKeyService _rsaKeyService; + private readonly ILogger _logger; + + public AuthController( + IAuthService authService, + IRsaKeyService rsaKeyService, + ILogger logger) + { + _authService = authService; + _rsaKeyService = rsaKeyService; + _logger = logger; + } +``` + +**Step 2: Add GetPublicKey endpoint** + +Add after the constructor: + +```csharp + /// + /// Gets the server's RSA public key for encrypting login credentials. + /// + [HttpGet("public-key")] + [AllowAnonymous] + [ProducesResponseType(typeof(PublicKeyResponse), StatusCodes.Status200OK)] + public ActionResult GetPublicKey() + { + var publicKeyPem = _rsaKeyService.GetPublicKeyPem(); + return Ok(new PublicKeyResponse(publicKeyPem)); + } +``` + +**Step 3: Update Login endpoint to accept encrypted payload** + +Replace the existing Login method: + +```csharp + /// + /// Authenticates a user with encrypted credentials and creates a session cookie. + /// + /// Encrypted login credentials + /// Cancellation token + /// Login result with user info on success + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status401Unauthorized)] + public async Task> Login( + [FromBody] EncryptedLoginRequest request, + CancellationToken ct) + { + LoginModel loginModel; + try + { + var ciphertext = Convert.FromBase64String(request.EncryptedData); + var plaintext = _rsaKeyService.Decrypt(ciphertext); + loginModel = JsonSerializer.Deserialize(plaintext) + ?? throw new InvalidOperationException("Deserialization returned null"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to decrypt login request"); + return BadRequest(new LoginResultModel(false, "Invalid encrypted payload", null)); + } + + var result = await _authService.AuthenticateAsync( + loginModel.Username, loginModel.Password, ct); + + if (!result.Success) + { + _logger.LogWarning("Failed login attempt for user {Username}", loginModel.Username); + return Unauthorized(new LoginResultModel(false, result.ErrorMessage, null)); + } + + // Sign out existing session + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + // Create claims identity from user info + var identity = ClaimsExtensions.FromUserInfo(result.User!); + var principal = new ClaimsPrincipal(identity); + + // Sign in with non-persistent cookie + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + principal, + new AuthenticationProperties { IsPersistent = false }); + + _logger.LogInformation("User {Username} logged in successfully", loginModel.Username); + return Ok(new LoginResultModel(true, null, result.User)); + } +``` + +**Step 4: Delete old LoginRequest model** + +Delete file: `NEW/src/JdeScoping.Api/Models/LoginRequest.cs` + +**Step 5: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Api/JdeScoping.Api.csproj` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add NEW/src/JdeScoping.Api/Controllers/AuthController.cs +git rm NEW/src/JdeScoping.Api/Models/LoginRequest.cs +git commit -m "feat(api): update AuthController for encrypted login" +``` + +--- + +## Task 6: Update AuthController Tests + +**Files:** +- Modify: `NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs` + +**Step 1: Update imports and add RSA helper** + +Replace the entire test file: + +```csharp +// NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using JdeScoping.Api.Controllers; +using JdeScoping.Core.Interfaces; +using JdeScoping.Core.Models; +using JdeScoping.Core.Models.Auth; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; + +namespace JdeScoping.Api.Tests.Controllers; + +public class AuthControllerTests +{ + private readonly IAuthService _authService; + private readonly IRsaKeyService _rsaKeyService; + private readonly ILogger _logger; + private readonly AuthController _controller; + private readonly RSA _testRsa; + + public AuthControllerTests() + { + _authService = Substitute.For(); + _rsaKeyService = Substitute.For(); + _logger = Substitute.For>(); + + // Setup test RSA key pair + _testRsa = RSA.Create(2048); + var publicKeyPem = _testRsa.ExportSubjectPublicKeyInfoPem(); + _rsaKeyService.GetPublicKeyPem().Returns(publicKeyPem); + _rsaKeyService.Decrypt(Arg.Any()) + .Returns(callInfo => + { + var ciphertext = callInfo.Arg(); + return _testRsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256); + }); + + _controller = new AuthController(_authService, _rsaKeyService, _logger); + } + + private EncryptedLoginRequest EncryptLoginModel(LoginModel model) + { + var json = JsonSerializer.Serialize(model); + var plaintext = Encoding.UTF8.GetBytes(json); + var ciphertext = _testRsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256); + return new EncryptedLoginRequest(Convert.ToBase64String(ciphertext)); + } + + [Fact] + public void GetPublicKey_ReturnsPublicKeyPem() + { + // Act + var result = _controller.GetPublicKey(); + + // Assert + result.Result.ShouldBeOfType(); + var okResult = (OkObjectResult)result.Result!; + var response = okResult.Value.ShouldBeOfType(); + response.PublicKeyPem.ShouldStartWith("-----BEGIN PUBLIC KEY-----"); + } + + [Fact] + public async Task Login_WithValidCredentials_ReturnsLoginResultWithUser() + { + // Arrange + var loginModel = new LoginModel { Username = "testuser", Password = "password123" }; + var request = EncryptLoginModel(loginModel); + var user = new UserInfo + { + Dn = "CN=testuser,DC=example,DC=com", + Username = "testuser", + FirstName = "Test", + LastName = "User", + EmailAddress = "test@example.com", + Title = "Developer" + }; + _authService.AuthenticateAsync("testuser", "password123", Arg.Any()) + .Returns(new AuthResult(true, user, null)); + + var httpContext = CreateMockHttpContext(); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + // Act + var result = await _controller.Login(request, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var okResult = (OkObjectResult)result.Result!; + var loginResult = okResult.Value.ShouldBeOfType(); + loginResult.Success.ShouldBeTrue(); + loginResult.User.ShouldNotBeNull(); + loginResult.User.Username.ShouldBe("testuser"); + } + + [Fact] + public async Task Login_WithInvalidCredentials_Returns401WithError() + { + // Arrange + var loginModel = new LoginModel { Username = "testuser", Password = "wrongpassword" }; + var request = EncryptLoginModel(loginModel); + _authService.AuthenticateAsync("testuser", "wrongpassword", Arg.Any()) + .Returns(new AuthResult(false, null, "Incorrect username or password")); + + var httpContext = CreateMockHttpContext(); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + // Act + var result = await _controller.Login(request, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var unauthorizedResult = (UnauthorizedObjectResult)result.Result!; + var loginResult = unauthorizedResult.Value.ShouldBeOfType(); + loginResult.Success.ShouldBeFalse(); + loginResult.ErrorMessage.ShouldBe("Incorrect username or password"); + } + + [Fact] + public async Task Login_WithInvalidEncryptedData_ReturnsBadRequest() + { + // Arrange + var request = new EncryptedLoginRequest("not-valid-base64!!!"); + + var httpContext = CreateMockHttpContext(); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + // Act + var result = await _controller.Login(request, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var badRequestResult = (BadRequestObjectResult)result.Result!; + var loginResult = badRequestResult.Value.ShouldBeOfType(); + loginResult.Success.ShouldBeFalse(); + loginResult.ErrorMessage.ShouldBe("Invalid encrypted payload"); + } + + [Fact] + public async Task Logout_ClearsAuthentication() + { + // Arrange + var httpContext = CreateAuthenticatedHttpContext("testuser"); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + // Act + var result = await _controller.Logout(); + + // Assert + result.ShouldBeOfType(); + } + + [Fact] + public void GetCurrentUser_WhenAuthenticated_ReturnsUserInfo() + { + // Arrange + var claims = new List + { + new(ClaimTypes.Name, "testuser"), + new(ClaimTypes.GivenName, "Test"), + new(ClaimTypes.Surname, "User"), + new(ClaimTypes.Email, "test@example.com"), + new("title", "Developer"), + new("dn", "CN=testuser,DC=example,DC=com") + }; + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext { User = principal }; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + // Act + var result = _controller.GetCurrentUser(); + + // Assert + result.Result.ShouldBeOfType(); + var okResult = (OkObjectResult)result.Result!; + var user = okResult.Value.ShouldBeOfType(); + user.Username.ShouldBe("testuser"); + } + + private static HttpContext CreateMockHttpContext() + { + var authServiceMock = Substitute.For(); + authServiceMock.SignOutAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + authServiceMock.SignInAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authServiceMock); + + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + + return httpContext; + } + + private static HttpContext CreateAuthenticatedHttpContext(string username) + { + var httpContext = CreateMockHttpContext(); + var claims = new List + { + new(ClaimTypes.Name, username), + new("dn", $"CN={username},DC=example,DC=com") + }; + var identity = new ClaimsIdentity(claims, "Test"); + httpContext.User = new ClaimsPrincipal(identity); + return httpContext; + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `dotnet test NEW/tests/JdeScoping.Api.Tests --filter "FullyQualifiedName~AuthControllerTests" --verbosity normal` +Expected: All 6 tests pass + +**Step 3: Commit** + +```bash +git add NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs +git commit -m "test(api): update AuthController tests for encrypted login" +``` + +--- + +## Task 7: Add Blazor.SubtleCrypto Package to Client + +**Files:** +- Modify: `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` + +**Step 1: Add package reference** + +Run: `dotnet add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj package Blazor.SubtleCrypto` + +**Step 2: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj +git commit -m "chore(client): add Blazor.SubtleCrypto package" +``` + +--- + +## Task 8: Create ICryptoService and Implementation + +**Files:** +- Create: `NEW/src/JdeScoping.Client/Services/ICryptoService.cs` +- Create: `NEW/src/JdeScoping.Client/Services/CryptoService.cs` + +**Step 1: Create ICryptoService interface** + +```csharp +// NEW/src/JdeScoping.Client/Services/ICryptoService.cs +using JdeScoping.Core.Models.Auth; + +namespace JdeScoping.Client.Services; + +/// +/// Service for encrypting data using server's RSA public key. +/// +public interface ICryptoService +{ + /// + /// Encrypts login credentials for transmission to server. + /// + /// Login credentials to encrypt + /// Base64-encoded encrypted data + Task EncryptLoginAsync(LoginModel model); +} +``` + +**Step 2: Create CryptoService implementation** + +```csharp +// NEW/src/JdeScoping.Client/Services/CryptoService.cs +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Blazor.SubtleCrypto; +using JdeScoping.Core.Models.Auth; + +namespace JdeScoping.Client.Services; + +/// +/// Encrypts login credentials using Web Crypto API via Blazor.SubtleCrypto. +/// +public class CryptoService : ICryptoService +{ + private readonly HttpClient _httpClient; + private readonly ICryptoService _cryptoProvider; + private CryptoKey? _serverPublicKey; + private readonly SemaphoreSlim _keyLock = new(1, 1); + + public CryptoService(HttpClient httpClient, ICryptoService cryptoProvider) + { + _httpClient = httpClient; + _cryptoProvider = cryptoProvider; + } + + public async Task EncryptLoginAsync(LoginModel model) + { + var publicKey = await GetOrFetchPublicKeyAsync(); + + var json = JsonSerializer.Serialize(model); + var plaintext = Encoding.UTF8.GetBytes(json); + + var encrypted = await _cryptoProvider.EncryptAsync( + plaintext, + publicKey, + new EncryptParams { Name = "RSA-OAEP" }); + + return Convert.ToBase64String(encrypted); + } + + private async Task GetOrFetchPublicKeyAsync() + { + if (_serverPublicKey is not null) + return _serverPublicKey; + + await _keyLock.WaitAsync(); + try + { + if (_serverPublicKey is not null) + return _serverPublicKey; + + var response = await _httpClient.GetFromJsonAsync("api/auth/public-key") + ?? throw new InvalidOperationException("Failed to fetch public key"); + + _serverPublicKey = await _cryptoProvider.ImportKeyAsync( + "spki", + Convert.FromBase64String(ExtractBase64FromPem(response.PublicKeyPem)), + new Algorithm { Name = "RSA-OAEP", Hash = "SHA-256" }, + false, + new[] { "encrypt" }); + + return _serverPublicKey; + } + finally + { + _keyLock.Release(); + } + } + + private static string ExtractBase64FromPem(string pem) + { + return pem + .Replace("-----BEGIN PUBLIC KEY-----", "") + .Replace("-----END PUBLIC KEY-----", "") + .Replace("\n", "") + .Replace("\r", "") + .Trim(); + } +} +``` + +**Step 3: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` +Expected: Build succeeded (may have warnings about Blazor.SubtleCrypto API - we'll fix in next step if needed) + +**Step 4: Commit** + +```bash +git add NEW/src/JdeScoping.Client/Services/ICryptoService.cs +git add NEW/src/JdeScoping.Client/Services/CryptoService.cs +git commit -m "feat(client): add CryptoService for login encryption" +``` + +--- + +## Task 9: Update Client AuthService and IAuthService + +**Files:** +- Modify: `NEW/src/JdeScoping.Client/Services/IAuthService.cs` +- Modify: `NEW/src/JdeScoping.Client/Services/AuthService.cs` +- Delete: `NEW/src/JdeScoping.Client/Models/LoginModel.cs` + +**Step 1: Update IAuthService to use shared models** + +Replace the entire file: + +```csharp +// NEW/src/JdeScoping.Client/Services/IAuthService.cs +using JdeScoping.Core.Models.Auth; + +namespace JdeScoping.Client.Services; + +/// +/// Service for authentication operations. +/// +public interface IAuthService +{ + /// + /// Attempts to log in with the provided credentials (encrypted). + /// + Task LoginAsync(LoginModel model); + + /// + /// Logs out the current user. + /// + Task LogoutAsync(); +} +``` + +**Step 2: Update AuthService to use encrypted login** + +Replace the entire file: + +```csharp +// NEW/src/JdeScoping.Client/Services/AuthService.cs +using System.Net.Http.Json; +using JdeScoping.Client.Auth; +using JdeScoping.Core.Models.Auth; + +namespace JdeScoping.Client.Services; + +/// +/// Handles authentication via encrypted API calls with cookie-based auth. +/// +public class AuthService : IAuthService +{ + private readonly HttpClient _httpClient; + private readonly ICryptoService _cryptoService; + private readonly AuthStateProvider _authStateProvider; + + public AuthService( + HttpClient httpClient, + ICryptoService cryptoService, + AuthStateProvider authStateProvider) + { + _httpClient = httpClient; + _cryptoService = cryptoService; + _authStateProvider = authStateProvider; + } + + public async Task LoginAsync(LoginModel model) + { + try + { + // Encrypt credentials + var encryptedData = await _cryptoService.EncryptLoginAsync(model); + var request = new EncryptedLoginRequest(encryptedData); + + // Send encrypted request + var response = await _httpClient.PostAsJsonAsync("api/auth/login", request); + + var result = await response.Content.ReadFromJsonAsync(); + if (result is null) + { + return new LoginResultModel(false, "Invalid response from server", null); + } + + if (result.Success && result.User is not null) + { + // Notify auth state provider of the login + var userViewModel = new UserInfoViewModel + { + Username = result.User.Username, + FirstName = result.User.FirstName, + LastName = result.User.LastName, + EmailAddress = result.User.EmailAddress, + Title = result.User.Title, + Dn = result.User.Dn + }; + await _authStateProvider.MarkUserAsAuthenticated(userViewModel); + } + + return result; + } + catch (Exception ex) + { + return new LoginResultModel(false, $"Login failed: {ex.Message}", null); + } + } + + public async Task LogoutAsync() + { + try + { + await _httpClient.PostAsync("api/auth/logout", null); + } + catch + { + // Even if logout API fails, clear local state + } + + await _authStateProvider.LogoutAsync(); + } +} +``` + +**Step 3: Delete old LoginModel** + +Delete file: `NEW/src/JdeScoping.Client/Models/LoginModel.cs` + +**Step 4: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` +Expected: Build succeeded + +**Step 5: Commit** + +```bash +git add NEW/src/JdeScoping.Client/Services/IAuthService.cs +git add NEW/src/JdeScoping.Client/Services/AuthService.cs +git rm NEW/src/JdeScoping.Client/Models/LoginModel.cs +git commit -m "feat(client): update AuthService to use encrypted login" +``` + +--- + +## Task 10: Update Login.razor to Use Shared Model + +**Files:** +- Modify: `NEW/src/JdeScoping.Client/Pages/Login.razor` + +**Step 1: Update the using statement and result handling** + +Change line 1 and add using at the top: + +```razor +@page "/login" +@using JdeScoping.Core.Models.Auth +@inject IAuthService AuthService +@inject NavigationManager NavigationManager +``` + +Update the HandleLoginAsync method (in the @code block) to use LoginResultModel: + +```csharp + private async Task HandleLoginAsync() + { + _isLoading = true; + _errorMessage = null; + + try + { + var result = await AuthService.LoginAsync(_loginModel); + + if (result.Success) + { + var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl; + NavigationManager.NavigateTo(returnUrl); + } + else + { + _errorMessage = result.ErrorMessage ?? "Login failed. Please check your credentials."; + } + } + catch (Exception ex) + { + _errorMessage = $"An error occurred: {ex.Message}"; + } + finally + { + _isLoading = false; + } + } +``` + +**Step 2: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add NEW/src/JdeScoping.Client/Pages/Login.razor +git commit -m "feat(client): update Login.razor to use shared LoginModel" +``` + +--- + +## Task 11: Register CryptoService in Client DI + +**Files:** +- Modify: `NEW/src/JdeScoping.Client/Program.cs` + +**Step 1: Add CryptoService registration** + +Add after line 7 (after existing using statements): + +```csharp +using Blazor.SubtleCrypto; +``` + +Add after line 26 (after AuthStateProvider registration, before IAuthService): + +```csharp +// Crypto service for login encryption +builder.Services.AddSubtleCrypto(); +builder.Services.AddScoped(); +``` + +Update IAuthService registration (line 28) - it should still work as AuthService now takes ICryptoService. + +**Step 2: Verify build succeeds** + +Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add NEW/src/JdeScoping.Client/Program.cs +git commit -m "chore(client): register CryptoService in DI" +``` + +--- + +## Task 12: Add Client CryptoService Tests + +**Files:** +- Create: `NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs` +- Modify: `NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj` +- Delete: `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs` + +**Step 1: Update Client.Tests csproj to add required packages** + +Add to ItemGroup with PackageReferences: + +```xml + + +``` + +Also add project reference to Core: + +```xml + + + + +``` + +**Step 2: Create CryptoServiceTests** + +Note: Testing Blazor.SubtleCrypto directly is challenging because it requires browser APIs. We'll create integration-style tests that verify the service behavior with mocks. + +```csharp +// NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using JdeScoping.Client.Services; +using JdeScoping.Core.Models.Auth; +using NSubstitute; +using RichardSzalay.MockHttp; +using Shouldly; + +namespace JdeScoping.Client.Tests.Services; + +public class CryptoServiceTests +{ + [Fact] + public async Task EncryptLoginAsync_FetchesPublicKeyOnce() + { + // Arrange + using var rsa = RSA.Create(2048); + var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); + var mockHttp = new MockHttpMessageHandler(); + + // Setup public key endpoint - should only be called once + var keyRequest = mockHttp.Expect("/api/auth/public-key") + .Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem))); + + var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") }; + + // Create a mock crypto provider that just returns dummy encrypted data + var cryptoProvider = Substitute.For(); + cryptoProvider.ImportKeyAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new Blazor.SubtleCrypto.CryptoKey()); + cryptoProvider.EncryptAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new byte[] { 1, 2, 3, 4 }); + + var service = new CryptoService(httpClient, cryptoProvider); + var loginModel = new LoginModel { Username = "test", Password = "pass" }; + + // Act - call twice + await service.EncryptLoginAsync(loginModel); + await service.EncryptLoginAsync(loginModel); + + // Assert - public key fetched only once + mockHttp.GetMatchCount(keyRequest).ShouldBe(1); + } + + [Fact] + public async Task EncryptLoginAsync_ReturnsBase64String() + { + // Arrange + using var rsa = RSA.Create(2048); + var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When("/api/auth/public-key") + .Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem))); + + var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") }; + + var cryptoProvider = Substitute.For(); + cryptoProvider.ImportKeyAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new Blazor.SubtleCrypto.CryptoKey()); + cryptoProvider.EncryptAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new byte[] { 1, 2, 3, 4 }); + + var service = new CryptoService(httpClient, cryptoProvider); + var loginModel = new LoginModel { Username = "test", Password = "pass" }; + + // Act + var result = await service.EncryptLoginAsync(loginModel); + + // Assert - should be valid base64 + var decoded = Convert.FromBase64String(result); + decoded.ShouldNotBeEmpty(); + } +} +``` + +**Step 3: Delete placeholder file** + +Delete: `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test NEW/tests/JdeScoping.Client.Tests --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add NEW/tests/JdeScoping.Client.Tests/ +git rm NEW/tests/JdeScoping.Client.Tests/Placeholder.cs +git commit -m "test(client): add CryptoService tests" +``` + +--- + +## Task 13: Update Integration Tests + +**Files:** +- Modify: `NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs` + +**Step 1: Update integration tests for encrypted login** + +The integration tests need to use encrypted login requests. Update the test file to encrypt credentials before sending. + +Read the current file first to understand its structure, then update to use RSA encryption similar to the unit tests. + +**Step 2: Run all tests** + +Run: `dotnet test NEW/tests --verbosity normal` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs +git commit -m "test(integration): update auth tests for encrypted login" +``` + +--- + +## Task 14: Final Verification + +**Step 1: Build entire solution** + +Run: `dotnet build NEW/JdeScoping.sln` +Expected: Build succeeded with 0 errors + +**Step 2: Run all tests** + +Run: `dotnet test NEW/JdeScoping.sln --verbosity normal` +Expected: All tests pass + +**Step 3: Verify deleted files are gone** + +Confirm these files no longer exist: +- `NEW/src/JdeScoping.Api/Models/LoginRequest.cs` +- `NEW/src/JdeScoping.Client/Models/LoginModel.cs` + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat: complete encrypted login implementation" +``` + +--- + +## Summary + +**Files Created:** +- `NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs` +- `NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs` +- `NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs` +- `NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs` +- `NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs` +- `NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs` +- `NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs` +- `NEW/src/JdeScoping.Client/Services/ICryptoService.cs` +- `NEW/src/JdeScoping.Client/Services/CryptoService.cs` +- `NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs` +- `NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs` + +**Files Modified:** +- `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs` +- `NEW/src/JdeScoping.Api/Controllers/AuthController.cs` +- `NEW/src/JdeScoping.Client/Services/IAuthService.cs` +- `NEW/src/JdeScoping.Client/Services/AuthService.cs` +- `NEW/src/JdeScoping.Client/Pages/Login.razor` +- `NEW/src/JdeScoping.Client/Program.cs` +- `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj` +- `NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs` +- `NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj` +- `NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs` + +**Files Deleted:** +- `NEW/src/JdeScoping.Api/Models/LoginRequest.cs` +- `NEW/src/JdeScoping.Client/Models/LoginModel.cs` +- `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs` diff --git a/PLANS/2026-01-03-etl-pipeline-design.md b/PLANS/2026-01-03-etl-pipeline-design.md new file mode 100644 index 0000000..90e10f1 --- /dev/null +++ b/PLANS/2026-01-03-etl-pipeline-design.md @@ -0,0 +1,334 @@ +# ETL Pipeline Design for DataSync + +**Date:** 2026-01-03 +**Status:** Reviewed (Codex MCP) +**Purpose:** Replace the existing strongly-typed fetcher + source-generated DataReader + BulkMergeHelper system with a flexible, configuration-driven ETL pipeline. + +## Problem Statement + +The current DataSync system requires: +1. A source-generated `IDataReader` implementation per entity type +2. Strongly-typed `IDataFetcher` classes for each data source +3. Code changes to modify queries or add new sync operations + +**Primary drivers for change:** +- **Flexibility** - Enable ad-hoc queries and schema changes without code modifications +- **Observability** - Better error handling and row count tracking across pipeline steps + +## Design Overview + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ ImportSource│───▶│ Transformer │───▶│ Destination │ +│ (IDataReader) │ (decorates) │ │ (bulk ops) │ +└─────────────┘ └──────────────┘ └─────────────┘ + ▲ │ + │ ▼ +┌──────┴──────┐ ┌──────────────┐ +│ Pre-Scripts │ │ Post-Scripts │ +└─────────────┘ └──────────────┘ +``` + +All steps report to `PipelineResult` with timing and row counts. + +## Core Interfaces + +### IImportSource + +Reads data from a source system, returns `IDataReader` for streaming. + +```csharp +public interface IImportSource : IAsyncDisposable +{ + Task ReadDataAsync(CancellationToken cancellationToken = default); + string SourceName { get; } +} +``` + +**Implementations:** +- `DbQuerySource` - Executes SQL against Oracle/Sybase/SQL Server via `IDbConnectionFactory` +- `FileSource` - Reads CSV files with configurable delimiter/header options + +### IDataTransformer + +Wraps an `IDataReader` and applies transformations (column rename, drop, type conversion). + +```csharp +public interface IDataTransformer +{ + IDataReader Transform(IDataReader source); + string TransformerName { get; } +} +``` + +**Implementations:** +- `JdeDateTransformer` - Merges Julian date + time columns into `DateTime` +- `ColumnRenameTransformer` - Renames columns +- `ColumnDropTransformer` - Removes columns from output +- `ColumnAddTransformer` - Adds computed columns + +Transformers are registered by name in `ITransformerRegistry` for config-based hydration. + +### IImportDestination + +Writes data to a target table using bulk operations. + +```csharp +public interface IImportDestination +{ + Task WriteAsync( + IDataReader source, + CancellationToken cancellationToken = default); + string DestinationName { get; } +} + +public record DestinationResult( + long RowsProcessed, + int BatchCount, + TimeSpan Elapsed); +``` + +**Implementations:** + +#### DbBulkImportDestination (Full Refresh) +1. Truncate destination table +2. Bulk copy all data in batches +3. Per-batch commits + +#### DbBulkMergeDestination (Incremental) +1. Create temp table from destination schema +2. For each batch: + - Bulk copy to temp table + - Execute MERGE to destination + - Truncate temp table +3. Drop temp table on completion + +**Configuration options:** +- `tableName` - Target table +- `matchColumns` - PK columns for MERGE ON clause +- `updateColumns` - Columns to update (default: all non-match columns) +- `batchSize` - Rows per batch (default: 10000) + +### IScriptRunner + +Executes SQL scripts before or after the main pipeline. + +```csharp +public interface IScriptRunner +{ + Task ExecuteAsync(CancellationToken cancellationToken = default); + string ScriptName { get; } +} +``` + +**Common scripts (factory methods):** +- `CommonScripts.DisableIndexes(factory, tableName)` +- `CommonScripts.RebuildIndexes(factory, tableName)` +- `CommonScripts.UpdateStatistics(factory, tableName)` +- `CommonScripts.CustomSql(factory, sql, name)` + +### Pipeline Results + +```csharp +public record PipelineResult( + bool Success, + long TotalRows, + TimeSpan Elapsed, + IReadOnlyList Steps, + Exception? Error = null); + +public record StepResult( + string StepName, + string StepType, // "Source", "Transform", "Destination", "Script" + long RowsAffected, + TimeSpan Elapsed); +``` + +## EtlPipeline Orchestration + +```csharp +public class EtlPipeline +{ + public string PipelineName { get; } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + // 1. Run pre-scripts (fail-fast) + // 2. Open source, get IDataReader + // 3. Chain transformers (decorators) + // 4. Write to destination + // 5. Run post-scripts + // 6. Return PipelineResult with all step metrics + } +} +``` + +**Error handling:** +- Fail-fast on any error +- No cross-batch transactions (per-batch commits) +- Caller marks overall sync operation as failed +- Restart from beginning on failure (no resume) + +## Pipeline Construction + +### Fluent Builder + +```csharp +var pipeline = new EtlPipelineBuilder() + .WithName("WorkOrderSync") + .WithSource(new DbQuerySource(connFactory, "JDE", workOrderQuery)) + .WithTransformer(new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt")) + .WithDestination(new DbBulkMergeDestination(connFactory, "WorkOrder", ["OrderNumber"])) + .WithPreScript(CommonScripts.DisableIndexes(connFactory, "WorkOrder")) + .WithPostScript(CommonScripts.RebuildIndexes(connFactory, "WorkOrder")) + .WithLogger(logger) + .Build(); + +var result = await pipeline.ExecuteAsync(cancellationToken); +``` + +### Configuration-Based + +```json +{ + "name": "WorkOrderSync", + "source": { + "type": "db", + "connectionName": "JDE", + "query": "SELECT * FROM F4801 WHERE UPMJ >= :minDate" + }, + "transformers": [ + { "type": "jde-date", "options": { "dateColumn": "UPMJ", "timeColumn": "TDAY", "outputColumn": "UpdatedAt" } } + ], + "destination": { + "type": "bulk-merge", + "tableName": "WorkOrder", + "matchColumns": ["OrderNumber"], + "batchSize": 10000 + }, + "preScripts": [ + { "type": "disable-indexes", "tableName": "WorkOrder" } + ], + "postScripts": [ + { "type": "rebuild-indexes", "tableName": "WorkOrder" } + ] +} +``` + +Hydrated via `EtlPipelineFactory.CreateFromConfig(config)`. + +## Project Structure + +``` +NEW/src/JdeScoping.DataSync/ +├── Contracts/ +│ ├── IImportSource.cs +│ ├── IDataTransformer.cs +│ ├── IImportDestination.cs +│ ├── IScriptRunner.cs +│ └── ITransformerRegistry.cs +├── Etl/ +│ ├── Pipeline/ +│ │ ├── EtlPipeline.cs +│ │ ├── EtlPipelineBuilder.cs +│ │ └── EtlPipelineFactory.cs +│ ├── Sources/ +│ │ ├── DbQuerySource.cs +│ │ └── FileSource.cs +│ ├── Transformers/ +│ │ ├── DataTransformerBase.cs +│ │ ├── TransformingDataReader.cs +│ │ ├── JdeDateTransformer.cs +│ │ ├── ColumnRenameTransformer.cs +│ │ ├── ColumnDropTransformer.cs +│ │ └── TransformerRegistry.cs +│ ├── Destinations/ +│ │ ├── DbBulkImportDestination.cs +│ │ └── DbBulkMergeDestination.cs +│ ├── Scripts/ +│ │ ├── SqlScriptRunner.cs +│ │ └── CommonScripts.cs +│ └── Results/ +│ ├── PipelineResult.cs +│ ├── StepResult.cs +│ └── DestinationResult.cs +└── Config/ + └── PipelineConfig.cs +``` + +## Integration Strategy + +1. **Parallel existence** - New ETL pipeline lives alongside existing `IDataFetcher` + `IBulkMergeHelper` +2. **Gradual migration** - Convert one sync operation at a time to new pipeline +3. **Shared infrastructure** - Both use `IDbConnectionFactory`, logging, etc. +4. **Deprecation path** - Once all syncs migrated, remove old `Fetchers/` and source generator + +## DI Registration + +```csharp +public static class EtlServiceCollectionExtensions +{ + public static IServiceCollection AddEtlPipeline(this IServiceCollection services) + { + services.AddSingleton(); + services.AddTransient(); + return services; + } +} +``` + +## Design Decisions + +Based on Codex MCP review, the following decisions were made: + +### Atomicity & Consistency + +| Concern | Decision | Rationale | +|---------|----------|-----------| +| Full refresh partial visibility | **Accept** | Caller retries on failure; consumers tolerate brief inconsistency | +| Orphaned disabled indexes | **Accept risk** | Next successful run rebuilds; manual intervention rare | +| Temp table connection lifetime | **Destination owns connection** | `DbBulkMergeDestination` holds single connection for all batches | + +### Implementation Requirements + +1. **TransformingDataReader schema accuracy** - Transformers MUST correctly implement: + - `GetSchemaTable()` - Accurate after column changes + - `GetName(ordinal)` - Reflects renames + - `GetOrdinal(name)` - Works with new names + - `GetFieldType(ordinal)` - Correct after type transforms + - `FieldCount` - Accurate after drops/adds + +2. **Transform step row counts** - `StepResult.RowsAffected` is `0` for transform steps (inline decorators). Only destination reports actual rows processed. + +3. **Cancellation handling** - Destinations use `DbDataReader.ReadAsync()` internally, checking cancellation between rows. Source `IDataReader` interface stays synchronous for ADO.NET compatibility. + +4. **Schema validation** - Optional `ValidateSchema` step available to compare source columns against expected schema before pipeline runs. Not mandatory. + +5. **MERGE duplicate keys** - Source data MUST have unique keys for match columns. Pipeline does not dedupe; caller's responsibility to ensure uniqueness. + +### Connection Lifetime Rules + +``` +DbQuerySource: + - Opens connection in ReadDataAsync() + - Holds connection until DisposeAsync() + - Connection closed when pipeline completes or fails + +DbBulkImportDestination: + - Opens connection in WriteAsync() + - Holds for all batches + - Closes on completion or failure + +DbBulkMergeDestination: + - Opens connection in WriteAsync() + - Creates #temp table + - Holds same connection for all batch cycles + - Drops temp table in finally block + - Closes connection on completion or failure +``` + +## Open Questions + +1. Should `DbQuerySource` support parameterized queries with different parameter styles (`:param` for Oracle, `@param` for SQL Server)? +2. Should we support multiple destinations per pipeline (e.g., write to both current and history tables)? +3. How should pipeline configurations be stored and loaded (appsettings.json, separate files, database)? diff --git a/PLANS/2026-01-03-etl-pipeline-implementation.md b/PLANS/2026-01-03-etl-pipeline-implementation.md new file mode 100644 index 0000000..ad3bea7 --- /dev/null +++ b/PLANS/2026-01-03-etl-pipeline-implementation.md @@ -0,0 +1,2511 @@ +# ETL Pipeline Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement a flexible, configuration-driven ETL pipeline for DataSync that replaces the strongly-typed fetcher + source-generated DataReader pattern. + +**Architecture:** Pipeline components (Source → Transformer → Destination) connected via `IDataReader`. Transformers are decorators that wrap readers. Destinations own their connections. Pipeline orchestrates with fail-fast error handling and step-by-step metrics. + +**Tech Stack:** .NET 10, Microsoft.Data.SqlClient, xUnit, NSubstitute + +--- + +## Phase 1: Core Interfaces and Result Models + +### Task 1: Create Result Models + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Results/DestinationResult.cs` +- Create: `src/JdeScoping.DataSync/Etl/Results/StepResult.cs` +- Create: `src/JdeScoping.DataSync/Etl/Results/PipelineResult.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Results/PipelineResultTests.cs` + +**Step 1: Create DestinationResult record** + +```csharp +// src/JdeScoping.DataSync/Etl/Results/DestinationResult.cs +namespace JdeScoping.DataSync.Etl.Results; + +/// +/// Result from a destination write operation. +/// +public record DestinationResult( + long RowsProcessed, + int BatchCount, + TimeSpan Elapsed); +``` + +**Step 2: Create StepResult record** + +```csharp +// src/JdeScoping.DataSync/Etl/Results/StepResult.cs +namespace JdeScoping.DataSync.Etl.Results; + +/// +/// Result from a single pipeline step. +/// +public record StepResult( + string StepName, + string StepType, + long RowsAffected, + TimeSpan Elapsed); +``` + +**Step 3: Create PipelineResult record** + +```csharp +// src/JdeScoping.DataSync/Etl/Results/PipelineResult.cs +namespace JdeScoping.DataSync.Etl.Results; + +/// +/// Complete result from pipeline execution. +/// +public record PipelineResult( + bool Success, + long TotalRows, + TimeSpan Elapsed, + IReadOnlyList Steps, + Exception? Error = null) +{ + /// + /// Creates a successful result. + /// + public static PipelineResult Succeeded(long totalRows, TimeSpan elapsed, IReadOnlyList steps) + => new(true, totalRows, elapsed, steps); + + /// + /// Creates a failed result. + /// + public static PipelineResult Failed(long totalRows, TimeSpan elapsed, IReadOnlyList steps, Exception error) + => new(false, totalRows, elapsed, steps, error); +} +``` + +**Step 4: Write test for PipelineResult factory methods** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Results/PipelineResultTests.cs +namespace JdeScoping.DataSync.Tests.Etl.Results; + +using JdeScoping.DataSync.Etl.Results; + +public class PipelineResultTests +{ + [Fact] + public void Succeeded_CreatesSuccessfulResult() + { + var steps = new List + { + new("Source", "Source", 0, TimeSpan.FromSeconds(1)), + new("Destination", "Destination", 100, TimeSpan.FromSeconds(5)) + }; + + var result = PipelineResult.Succeeded(100, TimeSpan.FromSeconds(6), steps); + + Assert.True(result.Success); + Assert.Equal(100, result.TotalRows); + Assert.Null(result.Error); + Assert.Equal(2, result.Steps.Count); + } + + [Fact] + public void Failed_CreatesFailedResult() + { + var steps = new List(); + var error = new InvalidOperationException("Test error"); + + var result = PipelineResult.Failed(0, TimeSpan.FromSeconds(1), steps, error); + + Assert.False(result.Success); + Assert.Equal(0, result.TotalRows); + Assert.Same(error, result.Error); + } +} +``` + +**Step 5: Run tests** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~PipelineResultTests" --verbosity normal` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Results/ tests/JdeScoping.DataSync.Tests/Etl/Results/ +git commit -m "feat(etl): add result models for pipeline execution" +``` + +--- + +### Task 2: Create Core Interfaces + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Contracts/IImportSource.cs` +- Create: `src/JdeScoping.DataSync/Etl/Contracts/IDataTransformer.cs` +- Create: `src/JdeScoping.DataSync/Etl/Contracts/IImportDestination.cs` +- Create: `src/JdeScoping.DataSync/Etl/Contracts/IScriptRunner.cs` + +**Step 1: Create IImportSource interface** + +```csharp +// src/JdeScoping.DataSync/Etl/Contracts/IImportSource.cs +using System.Data; + +namespace JdeScoping.DataSync.Etl.Contracts; + +/// +/// Source that reads data and returns an IDataReader for streaming. +/// +public interface IImportSource : IAsyncDisposable +{ + /// + /// Opens the source and returns a data reader for streaming rows. + /// + Task ReadDataAsync(CancellationToken cancellationToken = default); + + /// + /// Name of this source for logging and metrics. + /// + string SourceName { get; } +} +``` + +**Step 2: Create IDataTransformer interface** + +```csharp +// src/JdeScoping.DataSync/Etl/Contracts/IDataTransformer.cs +using System.Data; + +namespace JdeScoping.DataSync.Etl.Contracts; + +/// +/// Transforms an IDataReader by wrapping it with transformation logic. +/// +public interface IDataTransformer +{ + /// + /// Wraps the source reader with transformation logic. + /// + IDataReader Transform(IDataReader source); + + /// + /// Name of this transformer for logging and metrics. + /// + string TransformerName { get; } +} +``` + +**Step 3: Create IImportDestination interface** + +```csharp +// src/JdeScoping.DataSync/Etl/Contracts/IImportDestination.cs +using System.Data; +using JdeScoping.DataSync.Etl.Results; + +namespace JdeScoping.DataSync.Etl.Contracts; + +/// +/// Destination that writes data from an IDataReader. +/// +public interface IImportDestination +{ + /// + /// Writes all rows from the reader to the destination. + /// + Task WriteAsync( + IDataReader source, + CancellationToken cancellationToken = default); + + /// + /// Name of this destination for logging and metrics. + /// + string DestinationName { get; } +} +``` + +**Step 4: Create IScriptRunner interface** + +```csharp +// src/JdeScoping.DataSync/Etl/Contracts/IScriptRunner.cs +namespace JdeScoping.DataSync.Etl.Contracts; + +/// +/// Runs a SQL script as part of pipeline pre/post processing. +/// +public interface IScriptRunner +{ + /// + /// Executes the script. + /// + Task ExecuteAsync(CancellationToken cancellationToken = default); + + /// + /// Name of this script for logging and metrics. + /// + string ScriptName { get; } +} +``` + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Contracts/ +git commit -m "feat(etl): add core ETL pipeline interfaces" +``` + +--- + +## Phase 2: Script Runner Implementation + +### Task 3: Implement SqlScriptRunner + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Scripts/SqlScriptRunnerTests.cs` + +**Step 1: Write failing test for SqlScriptRunner** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Scripts/SqlScriptRunnerTests.cs +using System.Data.Common; +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Scripts; +using Microsoft.Data.SqlClient; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Scripts; + +public class SqlScriptRunnerTests +{ + [Fact] + public void Constructor_SetsScriptName() + { + var factory = Substitute.For(); + + var runner = new SqlScriptRunner(factory, "SELECT 1", "TestScript"); + + Assert.Equal("TestScript", runner.ScriptName); + } + + [Fact] + public void Constructor_WithNullName_DefaultsToSqlScript() + { + var factory = Substitute.For(); + + var runner = new SqlScriptRunner(factory, "SELECT 1"); + + Assert.Equal("SqlScript", runner.ScriptName); + } + + [Fact] + public void Constructor_NullFactory_ThrowsArgumentNullException() + { + Assert.Throws(() => + new SqlScriptRunner(null!, "SELECT 1")); + } + + [Fact] + public void Constructor_NullSql_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new SqlScriptRunner(factory, null!)); + } + + [Fact] + public void Constructor_EmptySql_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new SqlScriptRunner(factory, "")); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~SqlScriptRunnerTests" --verbosity normal` +Expected: FAIL - SqlScriptRunner class does not exist + +**Step 3: Implement SqlScriptRunner** + +```csharp +// src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs +using System.Data; +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Contracts; + +namespace JdeScoping.DataSync.Etl.Scripts; + +/// +/// Runs a SQL script against the database. +/// +public class SqlScriptRunner : IScriptRunner +{ + private readonly IDbConnectionFactory _connectionFactory; + private readonly string _sql; + private readonly int _timeoutSeconds; + + public string ScriptName { get; } + + public SqlScriptRunner( + IDbConnectionFactory connectionFactory, + string sql, + string? name = null, + int timeoutSeconds = 3600) + { + ArgumentNullException.ThrowIfNull(connectionFactory); + ArgumentException.ThrowIfNullOrWhiteSpace(sql); + + _connectionFactory = connectionFactory; + _sql = sql; + _timeoutSeconds = timeoutSeconds; + ScriptName = name ?? "SqlScript"; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = _sql; + command.CommandTimeout = _timeoutSeconds; + await command.ExecuteNonQueryAsync(cancellationToken); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~SqlScriptRunnerTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs tests/JdeScoping.DataSync.Tests/Etl/Scripts/ +git commit -m "feat(etl): implement SqlScriptRunner" +``` + +--- + +### Task 4: Implement CommonScripts Factory + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs` + +**Step 1: Write failing test for CommonScripts** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Scripts; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Scripts; + +public class CommonScriptsTests +{ + private readonly IDbConnectionFactory _factory = Substitute.For(); + + [Fact] + public void DisableIndexes_ReturnsRunnerWithCorrectName() + { + var runner = CommonScripts.DisableIndexes(_factory, "WorkOrder"); + + Assert.Equal("DisableIndexes:WorkOrder", runner.ScriptName); + } + + [Fact] + public void RebuildIndexes_ReturnsRunnerWithCorrectName() + { + var runner = CommonScripts.RebuildIndexes(_factory, "WorkOrder"); + + Assert.Equal("RebuildIndexes:WorkOrder", runner.ScriptName); + } + + [Fact] + public void UpdateStatistics_ReturnsRunnerWithCorrectName() + { + var runner = CommonScripts.UpdateStatistics(_factory, "WorkOrder"); + + Assert.Equal("UpdateStats:WorkOrder", runner.ScriptName); + } + + [Fact] + public void CustomSql_ReturnsRunnerWithProvidedName() + { + var runner = CommonScripts.CustomSql(_factory, "SELECT 1", "MyCustomScript"); + + Assert.Equal("MyCustomScript", runner.ScriptName); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~CommonScriptsTests" --verbosity normal` +Expected: FAIL - CommonScripts class does not exist + +**Step 3: Implement CommonScripts** + +```csharp +// src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Contracts; + +namespace JdeScoping.DataSync.Etl.Scripts; + +/// +/// Factory methods for common SQL scripts used in ETL pipelines. +/// +public static class CommonScripts +{ + /// + /// Creates a script that disables non-clustered indexes on a table. + /// + public static IScriptRunner DisableIndexes(IDbConnectionFactory factory, string tableName) + { + var sql = $@" +DECLARE @sql NVARCHAR(MAX) = ''; +SELECT @sql = @sql + 'ALTER INDEX [' + i.name + '] ON [{tableName}] DISABLE;' + CHAR(13) +FROM sys.indexes i +INNER JOIN sys.tables t ON i.object_id = t.object_id +WHERE t.name = '{tableName}' + AND i.type = 2 + AND i.is_disabled = 0; +IF LEN(@sql) > 0 EXEC sp_executesql @sql;"; + + return new SqlScriptRunner(factory, sql, $"DisableIndexes:{tableName}", timeoutSeconds: 300); + } + + /// + /// Creates a script that rebuilds all indexes on a table. + /// + public static IScriptRunner RebuildIndexes(IDbConnectionFactory factory, string tableName) + { + var sql = $"ALTER INDEX ALL ON [{tableName}] REBUILD WITH (FILLFACTOR = 95)"; + return new SqlScriptRunner(factory, sql, $"RebuildIndexes:{tableName}", timeoutSeconds: 3600); + } + + /// + /// Creates a script that updates statistics on a table. + /// + public static IScriptRunner UpdateStatistics(IDbConnectionFactory factory, string tableName) + { + var sql = $"UPDATE STATISTICS [{tableName}]"; + return new SqlScriptRunner(factory, sql, $"UpdateStats:{tableName}", timeoutSeconds: 600); + } + + /// + /// Creates a script runner for custom SQL. + /// + public static IScriptRunner CustomSql(IDbConnectionFactory factory, string sql, string name) + { + return new SqlScriptRunner(factory, sql, name); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~CommonScriptsTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs +git commit -m "feat(etl): add CommonScripts factory for index and statistics scripts" +``` + +--- + +## Phase 3: Transformer Infrastructure + +### Task 5: Implement TransformingDataReader Base + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Transformers/TransformingDataReader.cs` +- Create: `src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/TransformingDataReaderTests.cs` + +**Step 1: Write failing test for TransformingDataReader** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Transformers/TransformingDataReaderTests.cs +using System.Data; +using JdeScoping.DataSync.Etl.Transformers; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Transformers; + +public class TransformingDataReaderTests +{ + [Fact] + public void Read_DelegatesToSourceReader() + { + var source = Substitute.For(); + source.Read().Returns(true, true, false); + + var transformer = new PassThroughTransformer(); + var reader = transformer.Transform(source); + + Assert.True(reader.Read()); + Assert.True(reader.Read()); + Assert.False(reader.Read()); + } + + [Fact] + public void FieldCount_DelegatesToTransformer() + { + var source = Substitute.For(); + source.FieldCount.Returns(5); + + var transformer = new PassThroughTransformer(); + var reader = transformer.Transform(source); + + Assert.Equal(5, reader.FieldCount); + } + + [Fact] + public void GetName_DelegatesToTransformer() + { + var source = Substitute.For(); + source.GetName(0).Returns("OriginalName"); + + var transformer = new PassThroughTransformer(); + var reader = transformer.Transform(source); + + Assert.Equal("OriginalName", reader.GetName(0)); + } + + [Fact] + public void GetValue_DelegatesToTransformer() + { + var source = Substitute.For(); + source.GetValue(0).Returns("TestValue"); + + var transformer = new PassThroughTransformer(); + var reader = transformer.Transform(source); + + Assert.Equal("TestValue", reader.GetValue(0)); + } + + [Fact] + public void Dispose_DisposesSourceReader() + { + var source = Substitute.For(); + + var transformer = new PassThroughTransformer(); + var reader = transformer.Transform(source); + reader.Dispose(); + + source.Received(1).Dispose(); + } + + // Test helper - pass-through transformer + private class PassThroughTransformer : DataTransformerBase + { + public override string TransformerName => "PassThrough"; + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~TransformingDataReaderTests" --verbosity normal` +Expected: FAIL - classes do not exist + +**Step 3: Implement DataTransformerBase** + +```csharp +// src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs +using System.Data; +using JdeScoping.DataSync.Etl.Contracts; + +namespace JdeScoping.DataSync.Etl.Transformers; + +/// +/// Base class for data transformers that wrap an IDataReader. +/// Override virtual methods to customize transformation behavior. +/// +public abstract class DataTransformerBase : IDataTransformer +{ + public abstract string TransformerName { get; } + + public IDataReader Transform(IDataReader source) + { + ArgumentNullException.ThrowIfNull(source); + OnInitialize(source); + return new TransformingDataReader(source, this); + } + + /// + /// Called once when transform is initialized. Use to cache ordinals. + /// + protected virtual void OnInitialize(IDataReader source) { } + + /// + /// Returns the number of fields after transformation. + /// + public virtual int GetFieldCount(IDataReader source) => source.FieldCount; + + /// + /// Returns the field name at the given ordinal after transformation. + /// + public virtual string GetName(int ordinal, IDataReader source) => source.GetName(ordinal); + + /// + /// Returns the field type at the given ordinal after transformation. + /// + public virtual Type GetFieldType(int ordinal, IDataReader source) => source.GetFieldType(ordinal); + + /// + /// Returns the value at the given ordinal after transformation. + /// + public virtual object GetValue(int ordinal, IDataReader source) => source.GetValue(ordinal); + + /// + /// Returns the ordinal for a field name after transformation. + /// + public virtual int GetOrdinal(string name, IDataReader source) => source.GetOrdinal(name); + + /// + /// Returns whether the value at the given ordinal is null. + /// + public virtual bool IsDBNull(int ordinal, IDataReader source) => source.IsDBNull(ordinal); +} +``` + +**Step 4: Implement TransformingDataReader** + +```csharp +// src/JdeScoping.DataSync/Etl/Transformers/TransformingDataReader.cs +using System.Data; + +namespace JdeScoping.DataSync.Etl.Transformers; + +/// +/// IDataReader wrapper that delegates to a transformer for value/schema operations. +/// +internal sealed class TransformingDataReader : IDataReader +{ + private readonly IDataReader _source; + private readonly DataTransformerBase _transformer; + + public TransformingDataReader(IDataReader source, DataTransformerBase transformer) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _transformer = transformer ?? throw new ArgumentNullException(nameof(transformer)); + } + + // Schema properties - delegate to transformer + public int FieldCount => _transformer.GetFieldCount(_source); + public string GetName(int i) => _transformer.GetName(i, _source); + public Type GetFieldType(int i) => _transformer.GetFieldType(i, _source); + public int GetOrdinal(string name) => _transformer.GetOrdinal(name, _source); + + // Value access - delegate to transformer + public object GetValue(int i) => _transformer.GetValue(i, _source); + public bool IsDBNull(int i) => _transformer.IsDBNull(i, _source); + public object this[int i] => GetValue(i); + public object this[string name] => GetValue(GetOrdinal(name)); + + // Row navigation - delegate to source + public bool Read() => _source.Read(); + public bool NextResult() => _source.NextResult(); + public int Depth => _source.Depth; + public bool IsClosed => _source.IsClosed; + public int RecordsAffected => _source.RecordsAffected; + public void Close() => _source.Close(); + public void Dispose() => _source.Dispose(); + + // Type-specific getters - delegate to transformer via GetValue + public bool GetBoolean(int i) => (bool)GetValue(i); + public byte GetByte(int i) => (byte)GetValue(i); + public char GetChar(int i) => (char)GetValue(i); + public DateTime GetDateTime(int i) => (DateTime)GetValue(i); + public decimal GetDecimal(int i) => (decimal)GetValue(i); + public double GetDouble(int i) => (double)GetValue(i); + public float GetFloat(int i) => (float)GetValue(i); + public Guid GetGuid(int i) => (Guid)GetValue(i); + public short GetInt16(int i) => (short)GetValue(i); + public int GetInt32(int i) => (int)GetValue(i); + public long GetInt64(int i) => (long)GetValue(i); + public string GetString(int i) => (string)GetValue(i); + + public string GetDataTypeName(int i) => _source.GetDataTypeName(i); + public int GetValues(object[] values) + { + var count = Math.Min(values.Length, FieldCount); + for (int i = 0; i < count; i++) + values[i] = GetValue(i); + return count; + } + + public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) + => _source.GetBytes(i, fieldOffset, buffer, bufferoffset, length); + + public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) + => _source.GetChars(i, fieldoffset, buffer, bufferoffset, length); + + public IDataReader GetData(int i) => _source.GetData(i); + + public DataTable? GetSchemaTable() => _source.GetSchemaTable(); +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~TransformingDataReaderTests" --verbosity normal` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Transformers/ tests/JdeScoping.DataSync.Tests/Etl/Transformers/ +git commit -m "feat(etl): implement TransformingDataReader and DataTransformerBase" +``` + +--- + +### Task 6: Implement ColumnDropTransformer + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs +using System.Data; +using JdeScoping.DataSync.Etl.Transformers; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Transformers; + +public class ColumnDropTransformerTests +{ + [Fact] + public void FieldCount_ExcludesDroppedColumns() + { + var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" }); + + var transformer = new ColumnDropTransformer("DropMe"); + var reader = transformer.Transform(source); + + Assert.Equal(3, reader.FieldCount); + } + + [Fact] + public void GetName_SkipsDroppedColumns() + { + var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" }); + + var transformer = new ColumnDropTransformer("DropMe"); + var reader = transformer.Transform(source); + + Assert.Equal("Id", reader.GetName(0)); + Assert.Equal("Name", reader.GetName(1)); + Assert.Equal("Value", reader.GetName(2)); + } + + [Fact] + public void GetOrdinal_ReturnsRemappedOrdinal() + { + var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" }); + + var transformer = new ColumnDropTransformer("DropMe"); + var reader = transformer.Transform(source); + + Assert.Equal(0, reader.GetOrdinal("Id")); + Assert.Equal(1, reader.GetOrdinal("Name")); + Assert.Equal(2, reader.GetOrdinal("Value")); + } + + [Fact] + public void GetOrdinal_DroppedColumn_ThrowsIndexOutOfRange() + { + var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" }); + + var transformer = new ColumnDropTransformer("DropMe"); + var reader = transformer.Transform(source); + + Assert.Throws(() => reader.GetOrdinal("DropMe")); + } + + [Fact] + public void GetValue_ReturnsCorrectValues() + { + var source = CreateMockReader( + new[] { "Id", "Name", "DropMe", "Value" }, + new object[] { 1, "Test", "Dropped", 42 }); + + var transformer = new ColumnDropTransformer("DropMe"); + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + Assert.Equal(1, reader.GetValue(0)); + Assert.Equal("Test", reader.GetValue(1)); + Assert.Equal(42, reader.GetValue(2)); + } + + [Fact] + public void MultipleDroppedColumns_AllExcluded() + { + var source = CreateMockReader(new[] { "Id", "Drop1", "Name", "Drop2", "Value" }); + + var transformer = new ColumnDropTransformer("Drop1", "Drop2"); + var reader = transformer.Transform(source); + + Assert.Equal(3, reader.FieldCount); + Assert.Equal("Id", reader.GetName(0)); + Assert.Equal("Name", reader.GetName(1)); + Assert.Equal("Value", reader.GetName(2)); + } + + private static IDataReader CreateMockReader(string[] columns, object[]? values = null) + { + var reader = Substitute.For(); + reader.FieldCount.Returns(columns.Length); + + for (int i = 0; i < columns.Length; i++) + { + var index = i; + reader.GetName(index).Returns(columns[index]); + reader.GetOrdinal(columns[index]).Returns(index); + if (values != null) + reader.GetValue(index).Returns(values[index]); + } + + return reader; + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnDropTransformerTests" --verbosity normal` +Expected: FAIL - ColumnDropTransformer does not exist + +**Step 3: Implement ColumnDropTransformer** + +```csharp +// src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs +using System.Data; + +namespace JdeScoping.DataSync.Etl.Transformers; + +/// +/// Transformer that removes columns from the output. +/// +public class ColumnDropTransformer : DataTransformerBase +{ + private readonly HashSet _columnsToDrop; + private int[]? _ordinalMap; // maps output ordinal -> source ordinal + private Dictionary? _nameToOrdinal; + + public override string TransformerName => $"DropColumns:{string.Join(",", _columnsToDrop)}"; + + public ColumnDropTransformer(params string[] columnsToDrop) + { + ArgumentNullException.ThrowIfNull(columnsToDrop); + _columnsToDrop = new HashSet(columnsToDrop, StringComparer.OrdinalIgnoreCase); + } + + protected override void OnInitialize(IDataReader source) + { + var ordinalList = new List(); + _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < source.FieldCount; i++) + { + var name = source.GetName(i); + if (!_columnsToDrop.Contains(name)) + { + _nameToOrdinal[name] = ordinalList.Count; + ordinalList.Add(i); + } + } + + _ordinalMap = ordinalList.ToArray(); + } + + public override int GetFieldCount(IDataReader source) => _ordinalMap!.Length; + + public override string GetName(int ordinal, IDataReader source) + => source.GetName(_ordinalMap![ordinal]); + + public override Type GetFieldType(int ordinal, IDataReader source) + => source.GetFieldType(_ordinalMap![ordinal]); + + public override object GetValue(int ordinal, IDataReader source) + => source.GetValue(_ordinalMap![ordinal]); + + public override int GetOrdinal(string name, IDataReader source) + { + if (_nameToOrdinal!.TryGetValue(name, out var ordinal)) + return ordinal; + throw new IndexOutOfRangeException($"Column '{name}' not found or was dropped."); + } + + public override bool IsDBNull(int ordinal, IDataReader source) + => source.IsDBNull(_ordinalMap![ordinal]); +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnDropTransformerTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs +git commit -m "feat(etl): implement ColumnDropTransformer" +``` + +--- + +### Task 7: Implement ColumnRenameTransformer + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs +using System.Data; +using JdeScoping.DataSync.Etl.Transformers; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Transformers; + +public class ColumnRenameTransformerTests +{ + [Fact] + public void GetName_ReturnsRenamedColumn() + { + var source = CreateMockReader(new[] { "OldName", "Other" }); + + var transformer = new ColumnRenameTransformer(("OldName", "NewName")); + var reader = transformer.Transform(source); + + Assert.Equal("NewName", reader.GetName(0)); + Assert.Equal("Other", reader.GetName(1)); + } + + [Fact] + public void GetOrdinal_FindsByNewName() + { + var source = CreateMockReader(new[] { "OldName", "Other" }); + + var transformer = new ColumnRenameTransformer(("OldName", "NewName")); + var reader = transformer.Transform(source); + + Assert.Equal(0, reader.GetOrdinal("NewName")); + } + + [Fact] + public void GetOrdinal_OldName_ThrowsIndexOutOfRange() + { + var source = CreateMockReader(new[] { "OldName", "Other" }); + + var transformer = new ColumnRenameTransformer(("OldName", "NewName")); + var reader = transformer.Transform(source); + + Assert.Throws(() => reader.GetOrdinal("OldName")); + } + + [Fact] + public void FieldCount_Unchanged() + { + var source = CreateMockReader(new[] { "OldName", "Other" }); + + var transformer = new ColumnRenameTransformer(("OldName", "NewName")); + var reader = transformer.Transform(source); + + Assert.Equal(2, reader.FieldCount); + } + + [Fact] + public void MultipleRenames_AllApplied() + { + var source = CreateMockReader(new[] { "A", "B", "C" }); + + var transformer = new ColumnRenameTransformer(("A", "Alpha"), ("C", "Charlie")); + var reader = transformer.Transform(source); + + Assert.Equal("Alpha", reader.GetName(0)); + Assert.Equal("B", reader.GetName(1)); + Assert.Equal("Charlie", reader.GetName(2)); + } + + private static IDataReader CreateMockReader(string[] columns) + { + var reader = Substitute.For(); + reader.FieldCount.Returns(columns.Length); + + for (int i = 0; i < columns.Length; i++) + { + var index = i; + reader.GetName(index).Returns(columns[index]); + reader.GetOrdinal(columns[index]).Returns(index); + } + + return reader; + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnRenameTransformerTests" --verbosity normal` +Expected: FAIL - ColumnRenameTransformer does not exist + +**Step 3: Implement ColumnRenameTransformer** + +```csharp +// src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs +using System.Data; + +namespace JdeScoping.DataSync.Etl.Transformers; + +/// +/// Transformer that renames columns in the output. +/// +public class ColumnRenameTransformer : DataTransformerBase +{ + private readonly Dictionary _renames; // old -> new + private string[]? _outputNames; + private Dictionary? _nameToOrdinal; + + public override string TransformerName => $"RenameColumns:{_renames.Count}"; + + public ColumnRenameTransformer(params (string OldName, string NewName)[] renames) + { + ArgumentNullException.ThrowIfNull(renames); + _renames = renames.ToDictionary( + r => r.OldName, + r => r.NewName, + StringComparer.OrdinalIgnoreCase); + } + + protected override void OnInitialize(IDataReader source) + { + _outputNames = new string[source.FieldCount]; + _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < source.FieldCount; i++) + { + var originalName = source.GetName(i); + var outputName = _renames.TryGetValue(originalName, out var newName) + ? newName + : originalName; + _outputNames[i] = outputName; + _nameToOrdinal[outputName] = i; + } + } + + public override string GetName(int ordinal, IDataReader source) + => _outputNames![ordinal]; + + public override int GetOrdinal(string name, IDataReader source) + { + if (_nameToOrdinal!.TryGetValue(name, out var ordinal)) + return ordinal; + throw new IndexOutOfRangeException($"Column '{name}' not found."); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnRenameTransformerTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs +git commit -m "feat(etl): implement ColumnRenameTransformer" +``` + +--- + +### Task 8: Implement JdeDateTransformer + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs +using System.Data; +using JdeScoping.DataSync.Etl.Transformers; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Transformers; + +public class JdeDateTransformerTests +{ + [Fact] + public void FieldCount_ReducedByOne() + { + var source = CreateMockReader( + new[] { "Id", "UPMJ", "TDAY", "Name" }, + new object[] { 1, 124001m, 120000m, "Test" }); + + var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); + var reader = transformer.Transform(source); + + Assert.Equal(3, reader.FieldCount); + } + + [Fact] + public void GetName_DateColumnRenamed_TimeColumnRemoved() + { + var source = CreateMockReader( + new[] { "Id", "UPMJ", "TDAY", "Name" }, + new object[] { 1, 124001m, 120000m, "Test" }); + + var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); + var reader = transformer.Transform(source); + + Assert.Equal("Id", reader.GetName(0)); + Assert.Equal("UpdatedAt", reader.GetName(1)); + Assert.Equal("Name", reader.GetName(2)); + } + + [Fact] + public void GetValue_ParsesJulianDateAndTime() + { + // Julian date 124001 = Jan 1, 2024 (century digit 1 = 2000s, year 24, day 001) + // Time 120000 = 12:00:00 + var source = CreateMockReader( + new[] { "Id", "UPMJ", "TDAY", "Name" }, + new object[] { 1, 124001m, 120000m, "Test" }); + source.Read().Returns(true); + + var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); + var reader = transformer.Transform(source); + reader.Read(); + + var expectedDate = new DateTime(2024, 1, 1, 12, 0, 0); + Assert.Equal(expectedDate, reader.GetValue(1)); + } + + [Fact] + public void GetValue_NullDate_ReturnsDbNull() + { + var source = CreateMockReader( + new[] { "Id", "UPMJ", "TDAY", "Name" }, + new object[] { 1, DBNull.Value, DBNull.Value, "Test" }); + source.Read().Returns(true); + + var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); + var reader = transformer.Transform(source); + reader.Read(); + + Assert.Equal(DBNull.Value, reader.GetValue(1)); + } + + [Fact] + public void GetFieldType_DateColumn_ReturnsDateTime() + { + var source = CreateMockReader( + new[] { "Id", "UPMJ", "TDAY", "Name" }, + new object[] { 1, 124001m, 120000m, "Test" }); + + var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); + var reader = transformer.Transform(source); + + Assert.Equal(typeof(DateTime), reader.GetFieldType(1)); + } + + [Fact] + public void GetOrdinal_NewDateColumn_ReturnsCorrectOrdinal() + { + var source = CreateMockReader( + new[] { "Id", "UPMJ", "TDAY", "Name" }, + new object[] { 1, 124001m, 120000m, "Test" }); + + var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); + var reader = transformer.Transform(source); + + Assert.Equal(1, reader.GetOrdinal("UpdatedAt")); + } + + private static IDataReader CreateMockReader(string[] columns, object[] values) + { + var reader = Substitute.For(); + reader.FieldCount.Returns(columns.Length); + + for (int i = 0; i < columns.Length; i++) + { + var index = i; + reader.GetName(index).Returns(columns[index]); + reader.GetOrdinal(columns[index]).Returns(index); + reader.GetValue(index).Returns(values[index]); + reader.IsDBNull(index).Returns(values[index] == DBNull.Value); + reader.GetFieldType(index).Returns(values[index]?.GetType() ?? typeof(object)); + } + + return reader; + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~JdeDateTransformerTests" --verbosity normal` +Expected: FAIL - JdeDateTransformer does not exist + +**Step 3: Implement JdeDateTransformer** + +```csharp +// src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs +using System.Data; + +namespace JdeScoping.DataSync.Etl.Transformers; + +/// +/// Transforms JDE Julian date (UPMJ) and time (TDAY) columns into a single DateTime column. +/// JDE Julian date format: CYYDDD where C=century (0=1900s, 1=2000s), YY=year, DDD=day of year. +/// JDE time format: HHMMSS as decimal. +/// +public class JdeDateTransformer : DataTransformerBase +{ + private readonly string _dateColumn; + private readonly string _timeColumn; + private readonly string _outputColumn; + + private int _dateOrdinal; + private int _timeOrdinal; + private int[]? _ordinalMap; + private string[]? _outputNames; + private Dictionary? _nameToOrdinal; + + public override string TransformerName => $"JdeDate:{_outputColumn}"; + + public JdeDateTransformer(string dateColumn, string timeColumn, string outputColumn) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dateColumn); + ArgumentException.ThrowIfNullOrWhiteSpace(timeColumn); + ArgumentException.ThrowIfNullOrWhiteSpace(outputColumn); + + _dateColumn = dateColumn; + _timeColumn = timeColumn; + _outputColumn = outputColumn; + } + + protected override void OnInitialize(IDataReader source) + { + _dateOrdinal = source.GetOrdinal(_dateColumn); + _timeOrdinal = source.GetOrdinal(_timeColumn); + + var ordinalList = new List(); + var nameList = new List(); + _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < source.FieldCount; i++) + { + if (i == _timeOrdinal) + continue; // Skip time column + + if (i == _dateOrdinal) + { + _nameToOrdinal[_outputColumn] = ordinalList.Count; + nameList.Add(_outputColumn); + } + else + { + var name = source.GetName(i); + _nameToOrdinal[name] = ordinalList.Count; + nameList.Add(name); + } + ordinalList.Add(i); + } + + _ordinalMap = ordinalList.ToArray(); + _outputNames = nameList.ToArray(); + } + + public override int GetFieldCount(IDataReader source) => _ordinalMap!.Length; + + public override string GetName(int ordinal, IDataReader source) => _outputNames![ordinal]; + + public override Type GetFieldType(int ordinal, IDataReader source) + { + var sourceOrdinal = _ordinalMap![ordinal]; + return sourceOrdinal == _dateOrdinal ? typeof(DateTime) : source.GetFieldType(sourceOrdinal); + } + + public override object GetValue(int ordinal, IDataReader source) + { + var sourceOrdinal = _ordinalMap![ordinal]; + + if (sourceOrdinal == _dateOrdinal) + return ParseJdeDateTime(source); + + return source.GetValue(sourceOrdinal); + } + + public override int GetOrdinal(string name, IDataReader source) + { + if (_nameToOrdinal!.TryGetValue(name, out var ordinal)) + return ordinal; + throw new IndexOutOfRangeException($"Column '{name}' not found."); + } + + public override bool IsDBNull(int ordinal, IDataReader source) + { + var sourceOrdinal = _ordinalMap![ordinal]; + if (sourceOrdinal == _dateOrdinal) + return source.IsDBNull(_dateOrdinal); + return source.IsDBNull(sourceOrdinal); + } + + private object ParseJdeDateTime(IDataReader source) + { + if (source.IsDBNull(_dateOrdinal)) + return DBNull.Value; + + var julianDate = Convert.ToDecimal(source.GetValue(_dateOrdinal)); + var timeValue = source.IsDBNull(_timeOrdinal) ? 0m : Convert.ToDecimal(source.GetValue(_timeOrdinal)); + + return ParseJdeDateTime(julianDate, timeValue); + } + + /// + /// Parses JDE Julian date and time into DateTime. + /// + public static DateTime ParseJdeDateTime(decimal julianDate, decimal time) + { + // CYYDDD format + var dateInt = (int)julianDate; + var century = dateInt / 100000; + var year = (dateInt / 1000) % 100; + var dayOfYear = dateInt % 1000; + + var fullYear = (century == 0 ? 1900 : 2000) + year; + var date = new DateTime(fullYear, 1, 1).AddDays(dayOfYear - 1); + + // HHMMSS format + var timeInt = (int)time; + var hours = timeInt / 10000; + var minutes = (timeInt / 100) % 100; + var seconds = timeInt % 100; + + return date.AddHours(hours).AddMinutes(minutes).AddSeconds(seconds); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~JdeDateTransformerTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs +git commit -m "feat(etl): implement JdeDateTransformer for Julian date parsing" +``` + +--- + +## Phase 4: Source Implementations + +### Task 9: Implement DbQuerySource + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Sources/DbQuerySource.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Sources/DbQuerySourceTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Sources/DbQuerySourceTests.cs +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Sources; +using Microsoft.Data.SqlClient; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Sources; + +public class DbQuerySourceTests +{ + [Fact] + public void Constructor_SetsSourceName() + { + var factory = Substitute.For(); + + var source = new DbQuerySource(factory, "SELECT 1", "TestSource"); + + Assert.Equal("DbQuery:TestSource", source.SourceName); + } + + [Fact] + public void Constructor_NullName_UsesDefault() + { + var factory = Substitute.For(); + + var source = new DbQuerySource(factory, "SELECT 1"); + + Assert.Equal("DbQuery:Query", source.SourceName); + } + + [Fact] + public void Constructor_NullFactory_ThrowsArgumentNullException() + { + Assert.Throws(() => + new DbQuerySource(null!, "SELECT 1")); + } + + [Fact] + public void Constructor_NullSql_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbQuerySource(factory, null!)); + } + + [Fact] + public void Constructor_EmptySql_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbQuerySource(factory, "")); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbQuerySourceTests" --verbosity normal` +Expected: FAIL - DbQuerySource does not exist + +**Step 3: Implement DbQuerySource** + +```csharp +// src/JdeScoping.DataSync/Etl/Sources/DbQuerySource.cs +using System.Data; +using System.Data.Common; +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Contracts; +using Microsoft.Data.SqlClient; + +namespace JdeScoping.DataSync.Etl.Sources; + +/// +/// Import source that executes a SQL query and returns results as IDataReader. +/// +public class DbQuerySource : IImportSource +{ + private readonly IDbConnectionFactory _connectionFactory; + private readonly string _sql; + private readonly object? _parameters; + private readonly int _commandTimeout; + + private SqlConnection? _connection; + private SqlCommand? _command; + + public string SourceName { get; } + + public DbQuerySource( + IDbConnectionFactory connectionFactory, + string sql, + string? name = null, + object? parameters = null, + int commandTimeout = 3600) + { + ArgumentNullException.ThrowIfNull(connectionFactory); + ArgumentException.ThrowIfNullOrWhiteSpace(sql); + + _connectionFactory = connectionFactory; + _sql = sql; + _parameters = parameters; + _commandTimeout = commandTimeout; + SourceName = $"DbQuery:{name ?? "Query"}"; + } + + public async Task ReadDataAsync(CancellationToken cancellationToken = default) + { + _connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); + _command = _connection.CreateCommand(); + _command.CommandText = _sql; + _command.CommandTimeout = _commandTimeout; + + AddParameters(_command, _parameters); + + return await _command.ExecuteReaderAsync(cancellationToken); + } + + private static void AddParameters(SqlCommand command, object? parameters) + { + if (parameters == null) + return; + + var properties = parameters.GetType().GetProperties(); + foreach (var prop in properties) + { + var value = prop.GetValue(parameters) ?? DBNull.Value; + command.Parameters.AddWithValue($"@{prop.Name}", value); + } + } + + public async ValueTask DisposeAsync() + { + if (_command != null) + { + await _command.DisposeAsync(); + _command = null; + } + if (_connection != null) + { + await _connection.DisposeAsync(); + _connection = null; + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbQuerySourceTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Sources/DbQuerySource.cs tests/JdeScoping.DataSync.Tests/Etl/Sources/ +git commit -m "feat(etl): implement DbQuerySource for database queries" +``` + +--- + +## Phase 5: Destination Implementations + +### Task 10: Implement DbBulkImportDestination + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkImportDestinationTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkImportDestinationTests.cs +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Destinations; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Destinations; + +public class DbBulkImportDestinationTests +{ + [Fact] + public void Constructor_SetsDestinationName() + { + var factory = Substitute.For(); + + var dest = new DbBulkImportDestination(factory, "WorkOrder"); + + Assert.Equal("BulkImport:WorkOrder", dest.DestinationName); + } + + [Fact] + public void Constructor_NullFactory_ThrowsArgumentNullException() + { + Assert.Throws(() => + new DbBulkImportDestination(null!, "WorkOrder")); + } + + [Fact] + public void Constructor_NullTableName_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbBulkImportDestination(factory, null!)); + } + + [Fact] + public void Constructor_EmptyTableName_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbBulkImportDestination(factory, "")); + } + + [Theory] + [InlineData(0, 10000)] // 0 means default + [InlineData(5000, 5000)] + [InlineData(50000, 50000)] + public void Constructor_BatchSize_SetsCorrectly(int input, int expected) + { + var factory = Substitute.For(); + + var dest = new DbBulkImportDestination(factory, "WorkOrder", batchSize: input); + + // We can't easily test internal batch size, but construction should succeed + Assert.NotNull(dest); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkImportDestinationTests" --verbosity normal` +Expected: FAIL - DbBulkImportDestination does not exist + +**Step 3: Implement DbBulkImportDestination** + +```csharp +// src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs +using System.Data; +using System.Diagnostics; +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Contracts; +using JdeScoping.DataSync.Etl.Results; +using Microsoft.Data.SqlClient; + +namespace JdeScoping.DataSync.Etl.Destinations; + +/// +/// Destination that truncates the target table and bulk loads all data. +/// +public class DbBulkImportDestination : IImportDestination +{ + private const int DefaultBatchSize = 10000; + + private readonly IDbConnectionFactory _connectionFactory; + private readonly string _tableName; + private readonly int _batchSize; + + public string DestinationName => $"BulkImport:{_tableName}"; + + public DbBulkImportDestination( + IDbConnectionFactory connectionFactory, + string tableName, + int batchSize = 0) + { + ArgumentNullException.ThrowIfNull(connectionFactory); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + + _connectionFactory = connectionFactory; + _tableName = tableName; + _batchSize = batchSize > 0 ? batchSize : DefaultBatchSize; + } + + public async Task WriteAsync( + IDataReader source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + + var stopwatch = Stopwatch.StartNew(); + long totalRows = 0; + int batchCount = 0; + + await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); + + // Truncate destination table + await using (var truncateCmd = connection.CreateCommand()) + { + truncateCmd.CommandText = $"TRUNCATE TABLE [{_tableName}]"; + await truncateCmd.ExecuteNonQueryAsync(cancellationToken); + } + + // Bulk copy data + using var bulkCopy = new SqlBulkCopy(connection) + { + DestinationTableName = $"[{_tableName}]", + BatchSize = _batchSize, + BulkCopyTimeout = 3600, + EnableStreaming = true + }; + + // Map columns by name + for (int i = 0; i < source.FieldCount; i++) + { + bulkCopy.ColumnMappings.Add(source.GetName(i), source.GetName(i)); + } + + // Track rows via event + bulkCopy.NotifyAfter = _batchSize; + bulkCopy.SqlRowsCopied += (_, e) => + { + totalRows = e.RowsCopied; + batchCount++; + }; + + await bulkCopy.WriteToServerAsync(source, cancellationToken); + + // Final count (in case NotifyAfter didn't fire for last partial batch) + if (bulkCopy.RowsCopied > totalRows) + { + totalRows = bulkCopy.RowsCopied; + batchCount++; + } + + stopwatch.Stop(); + return new DestinationResult(totalRows, batchCount, stopwatch.Elapsed); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkImportDestinationTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs tests/JdeScoping.DataSync.Tests/Etl/Destinations/ +git commit -m "feat(etl): implement DbBulkImportDestination for full table refresh" +``` + +--- + +### Task 11: Implement DbBulkMergeDestination + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Destinations; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Destinations; + +public class DbBulkMergeDestinationTests +{ + [Fact] + public void Constructor_SetsDestinationName() + { + var factory = Substitute.For(); + + var dest = new DbBulkMergeDestination(factory, "WorkOrder", new[] { "OrderNumber" }); + + Assert.Equal("BulkMerge:WorkOrder", dest.DestinationName); + } + + [Fact] + public void Constructor_NullFactory_ThrowsArgumentNullException() + { + Assert.Throws(() => + new DbBulkMergeDestination(null!, "WorkOrder", new[] { "Id" })); + } + + [Fact] + public void Constructor_NullTableName_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbBulkMergeDestination(factory, null!, new[] { "Id" })); + } + + [Fact] + public void Constructor_EmptyMatchColumns_ThrowsArgumentException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbBulkMergeDestination(factory, "WorkOrder", Array.Empty())); + } + + [Fact] + public void Constructor_NullMatchColumns_ThrowsArgumentNullException() + { + var factory = Substitute.For(); + + Assert.Throws(() => + new DbBulkMergeDestination(factory, "WorkOrder", null!)); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkMergeDestinationTests" --verbosity normal` +Expected: FAIL - DbBulkMergeDestination does not exist + +**Step 3: Implement DbBulkMergeDestination** + +```csharp +// src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs +using System.Data; +using System.Diagnostics; +using System.Text; +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Etl.Contracts; +using JdeScoping.DataSync.Etl.Results; +using Microsoft.Data.SqlClient; + +namespace JdeScoping.DataSync.Etl.Destinations; + +/// +/// Destination that uses temp table + MERGE for incremental updates. +/// +public class DbBulkMergeDestination : IImportDestination +{ + private const int DefaultBatchSize = 10000; + + private readonly IDbConnectionFactory _connectionFactory; + private readonly string _tableName; + private readonly string[] _matchColumns; + private readonly string[]? _updateColumns; + private readonly int _batchSize; + + public string DestinationName => $"BulkMerge:{_tableName}"; + + public DbBulkMergeDestination( + IDbConnectionFactory connectionFactory, + string tableName, + string[] matchColumns, + string[]? updateColumns = null, + int batchSize = 0) + { + ArgumentNullException.ThrowIfNull(connectionFactory); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + ArgumentNullException.ThrowIfNull(matchColumns); + if (matchColumns.Length == 0) + throw new ArgumentException("At least one match column is required.", nameof(matchColumns)); + + _connectionFactory = connectionFactory; + _tableName = tableName; + _matchColumns = matchColumns; + _updateColumns = updateColumns; + _batchSize = batchSize > 0 ? batchSize : DefaultBatchSize; + } + + public async Task WriteAsync( + IDataReader source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + + var stopwatch = Stopwatch.StartNew(); + long totalRows = 0; + int batchCount = 0; + + var tempTableName = $"#ETL_{_tableName.Replace(".", "_").Replace("[", "").Replace("]", "")}"; + + await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); + + try + { + // Create temp table from destination schema + await CreateTempTableAsync(connection, tempTableName, cancellationToken); + + // Get all column names from source + var allColumns = new List(); + for (int i = 0; i < source.FieldCount; i++) + allColumns.Add(source.GetName(i)); + + // Determine update columns (all non-match columns if not specified) + var matchSet = new HashSet(_matchColumns, StringComparer.OrdinalIgnoreCase); + var updateCols = _updateColumns ?? allColumns.Where(c => !matchSet.Contains(c)).ToArray(); + + // Build MERGE SQL + var mergeSql = BuildMergeSql(allColumns, updateCols); + + // Process in batches using DataTable buffer + var batch = new DataTable(); + SetupDataTable(batch, source); + + while (source.Read()) + { + var row = batch.NewRow(); + for (int i = 0; i < source.FieldCount; i++) + row[i] = source.GetValue(i); + batch.Rows.Add(row); + + if (batch.Rows.Count >= _batchSize) + { + batchCount++; + await ProcessBatchAsync(connection, batch, tempTableName, mergeSql, cancellationToken); + totalRows += batch.Rows.Count; + batch.Clear(); + } + } + + // Process remaining rows + if (batch.Rows.Count > 0) + { + batchCount++; + await ProcessBatchAsync(connection, batch, tempTableName, mergeSql, cancellationToken); + totalRows += batch.Rows.Count; + } + + stopwatch.Stop(); + return new DestinationResult(totalRows, batchCount, stopwatch.Elapsed); + } + finally + { + await DropTempTableAsync(connection, tempTableName); + } + } + + private async Task CreateTempTableAsync(SqlConnection connection, string tempTableName, CancellationToken ct) + { + var sql = $"SELECT TOP 0 * INTO [{tempTableName}] FROM [{_tableName}]"; + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(ct); + } + + private async Task DropTempTableAsync(SqlConnection connection, string tempTableName) + { + try + { + var sql = $"IF OBJECT_ID('tempdb..{tempTableName}') IS NOT NULL DROP TABLE [{tempTableName}]"; + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + catch { /* Ignore cleanup errors */ } + } + + private async Task ProcessBatchAsync( + SqlConnection connection, + DataTable batch, + string tempTableName, + string mergeSql, + CancellationToken ct) + { + // Bulk copy to temp table + using var bulkCopy = new SqlBulkCopy(connection) + { + DestinationTableName = tempTableName, + BatchSize = batch.Rows.Count + }; + await bulkCopy.WriteToServerAsync(batch, ct); + + // Execute MERGE + await using var cmd = connection.CreateCommand(); + cmd.CommandText = mergeSql; + await cmd.ExecuteNonQueryAsync(ct); + + // Truncate temp table + cmd.CommandText = $"TRUNCATE TABLE [{tempTableName}]"; + await cmd.ExecuteNonQueryAsync(ct); + } + + private string BuildMergeSql(IReadOnlyList allColumns, IReadOnlyList updateColumns) + { + var tempTableName = $"#ETL_{_tableName.Replace(".", "_").Replace("[", "").Replace("]", "")}"; + var sb = new StringBuilder(); + + sb.AppendLine($"MERGE INTO [{_tableName}] AS target"); + sb.AppendLine($"USING [{tempTableName}] AS source"); + sb.Append("ON "); + sb.AppendLine(string.Join(" AND ", _matchColumns.Select(c => $"target.[{c}] = source.[{c}]"))); + + if (updateColumns.Count > 0) + { + sb.AppendLine("WHEN MATCHED THEN UPDATE SET"); + sb.AppendLine(string.Join(", ", updateColumns.Select(c => $"target.[{c}] = source.[{c}]"))); + } + + sb.AppendLine("WHEN NOT MATCHED THEN INSERT"); + sb.AppendLine($"({string.Join(", ", allColumns.Select(c => $"[{c}]"))})"); + sb.AppendLine($"VALUES ({string.Join(", ", allColumns.Select(c => $"source.[{c}]"))});"); + + return sb.ToString(); + } + + private static void SetupDataTable(DataTable table, IDataReader source) + { + for (int i = 0; i < source.FieldCount; i++) + { + var type = source.GetFieldType(i); + // Handle nullable types + if (type.IsValueType) + type = typeof(Nullable<>).MakeGenericType(type); + table.Columns.Add(source.GetName(i), Nullable.GetUnderlyingType(type) ?? type); + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkMergeDestinationTests" --verbosity normal` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs +git commit -m "feat(etl): implement DbBulkMergeDestination for incremental updates" +``` + +--- + +## Phase 6: Pipeline Orchestration + +### Task 12: Implement EtlPipeline + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/Pipeline/EtlPipeline.cs` +- Test: `tests/JdeScoping.DataSync.Tests/Etl/Pipeline/EtlPipelineTests.cs` + +**Step 1: Write failing tests** + +```csharp +// tests/JdeScoping.DataSync.Tests/Etl/Pipeline/EtlPipelineTests.cs +using System.Data; +using JdeScoping.DataSync.Etl.Contracts; +using JdeScoping.DataSync.Etl.Pipeline; +using JdeScoping.DataSync.Etl.Results; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Pipeline; + +public class EtlPipelineTests +{ + [Fact] + public async Task ExecuteAsync_SuccessfulPipeline_ReturnsSuccessResult() + { + var source = CreateMockSource(); + var destination = CreateMockDestination(100); + + var pipeline = new EtlPipelineBuilder() + .WithName("TestPipeline") + .WithSource(source) + .WithDestination(destination) + .WithLogger(NullLogger.Instance) + .Build(); + + var result = await pipeline.ExecuteAsync(); + + Assert.True(result.Success); + Assert.Equal(100, result.TotalRows); + Assert.Null(result.Error); + } + + [Fact] + public async Task ExecuteAsync_WithPreScript_RunsScriptBeforeDestination() + { + var callOrder = new List(); + + var source = CreateMockSource(); + var destination = CreateMockDestination(100); + destination.When(d => d.WriteAsync(Arg.Any(), Arg.Any())) + .Do(_ => callOrder.Add("destination")); + + var preScript = Substitute.For(); + preScript.ScriptName.Returns("PreScript"); + preScript.When(s => s.ExecuteAsync(Arg.Any())) + .Do(_ => callOrder.Add("prescript")); + + var pipeline = new EtlPipelineBuilder() + .WithName("TestPipeline") + .WithSource(source) + .WithDestination(destination) + .WithPreScript(preScript) + .WithLogger(NullLogger.Instance) + .Build(); + + await pipeline.ExecuteAsync(); + + Assert.Equal(new[] { "prescript", "destination" }, callOrder); + } + + [Fact] + public async Task ExecuteAsync_DestinationFails_ReturnsFailedResult() + { + var source = CreateMockSource(); + var destination = Substitute.For(); + destination.DestinationName.Returns("FailingDest"); + destination.WriteAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Destination failed")); + + var pipeline = new EtlPipelineBuilder() + .WithName("TestPipeline") + .WithSource(source) + .WithDestination(destination) + .WithLogger(NullLogger.Instance) + .Build(); + + var result = await pipeline.ExecuteAsync(); + + Assert.False(result.Success); + Assert.NotNull(result.Error); + Assert.IsType(result.Error); + } + + [Fact] + public async Task ExecuteAsync_TracksStepResults() + { + var source = CreateMockSource(); + var destination = CreateMockDestination(100); + + var pipeline = new EtlPipelineBuilder() + .WithName("TestPipeline") + .WithSource(source) + .WithDestination(destination) + .WithLogger(NullLogger.Instance) + .Build(); + + var result = await pipeline.ExecuteAsync(); + + Assert.Equal(2, result.Steps.Count); + Assert.Equal("Source", result.Steps[0].StepType); + Assert.Equal("Destination", result.Steps[1].StepType); + } + + private static IImportSource CreateMockSource() + { + var reader = Substitute.For(); + reader.Read().Returns(false); + reader.FieldCount.Returns(0); + + var source = Substitute.For(); + source.SourceName.Returns("MockSource"); + source.ReadDataAsync(Arg.Any()) + .Returns(Task.FromResult(reader)); + return source; + } + + private static IImportDestination CreateMockDestination(long rows) + { + var destination = Substitute.For(); + destination.DestinationName.Returns("MockDestination"); + destination.WriteAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DestinationResult(rows, 1, TimeSpan.FromSeconds(1)))); + return destination; + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~EtlPipelineTests" --verbosity normal` +Expected: FAIL - EtlPipeline and EtlPipelineBuilder do not exist + +**Step 3: Implement EtlPipeline** + +```csharp +// src/JdeScoping.DataSync/Etl/Pipeline/EtlPipeline.cs +using System.Data; +using System.Diagnostics; +using JdeScoping.DataSync.Etl.Contracts; +using JdeScoping.DataSync.Etl.Results; +using Microsoft.Extensions.Logging; + +namespace JdeScoping.DataSync.Etl.Pipeline; + +/// +/// Orchestrates ETL pipeline execution: source → transformers → destination. +/// +public class EtlPipeline +{ + private readonly IImportSource _source; + private readonly IReadOnlyList _transformers; + private readonly IImportDestination _destination; + private readonly IReadOnlyList _preScripts; + private readonly IReadOnlyList _postScripts; + private readonly ILogger _logger; + + public string PipelineName { get; } + + internal EtlPipeline( + string name, + IImportSource source, + IReadOnlyList transformers, + IImportDestination destination, + IReadOnlyList preScripts, + IReadOnlyList postScripts, + ILogger logger) + { + PipelineName = name; + _source = source; + _transformers = transformers; + _destination = destination; + _preScripts = preScripts; + _postScripts = postScripts; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + var steps = new List(); + var totalStopwatch = Stopwatch.StartNew(); + + _logger.LogInformation("Starting pipeline {PipelineName}", PipelineName); + + try + { + // 1. Run pre-scripts + foreach (var script in _preScripts) + { + var stepResult = await RunScriptAsync(script, cancellationToken); + steps.Add(stepResult); + } + + // 2. Open source + var sourceStopwatch = Stopwatch.StartNew(); + await using (_source) + { + var reader = await _source.ReadDataAsync(cancellationToken); + sourceStopwatch.Stop(); + steps.Add(new StepResult(_source.SourceName, "Source", 0, sourceStopwatch.Elapsed)); + + // 3. Apply transformers (chain of decorators) + foreach (var transformer in _transformers) + { + var transformStopwatch = Stopwatch.StartNew(); + reader = transformer.Transform(reader); + transformStopwatch.Stop(); + steps.Add(new StepResult(transformer.TransformerName, "Transform", 0, transformStopwatch.Elapsed)); + } + + // 4. Write to destination + var destResult = await _destination.WriteAsync(reader, cancellationToken); + steps.Add(new StepResult( + _destination.DestinationName, + "Destination", + destResult.RowsProcessed, + destResult.Elapsed)); + } + + // 5. Run post-scripts + foreach (var script in _postScripts) + { + var stepResult = await RunScriptAsync(script, cancellationToken); + steps.Add(stepResult); + } + + totalStopwatch.Stop(); + var totalRows = steps.Sum(s => s.RowsAffected); + + _logger.LogInformation( + "Pipeline {PipelineName} completed. Rows={Rows}, Elapsed={Elapsed}ms", + PipelineName, totalRows, totalStopwatch.ElapsedMilliseconds); + + return PipelineResult.Succeeded(totalRows, totalStopwatch.Elapsed, steps); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + totalStopwatch.Stop(); + var totalRows = steps.Sum(s => s.RowsAffected); + + _logger.LogError(ex, + "Pipeline {PipelineName} failed at step {Step}", + PipelineName, steps.LastOrDefault()?.StepName ?? "Unknown"); + + return PipelineResult.Failed(totalRows, totalStopwatch.Elapsed, steps, ex); + } + } + + private async Task RunScriptAsync(IScriptRunner script, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + _logger.LogDebug("Running script {ScriptName}", script.ScriptName); + await script.ExecuteAsync(cancellationToken); + stopwatch.Stop(); + return new StepResult(script.ScriptName, "Script", 0, stopwatch.Elapsed); + } +} +``` + +**Step 4: Implement EtlPipelineBuilder** + +```csharp +// src/JdeScoping.DataSync/Etl/Pipeline/EtlPipelineBuilder.cs +using JdeScoping.DataSync.Etl.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JdeScoping.DataSync.Etl.Pipeline; + +/// +/// Fluent builder for constructing ETL pipelines. +/// +public class EtlPipelineBuilder +{ + private string _name = "Unnamed"; + private IImportSource? _source; + private readonly List _transformers = new(); + private IImportDestination? _destination; + private readonly List _preScripts = new(); + private readonly List _postScripts = new(); + private ILogger? _logger; + + public EtlPipelineBuilder WithName(string name) + { + _name = name ?? throw new ArgumentNullException(nameof(name)); + return this; + } + + public EtlPipelineBuilder WithSource(IImportSource source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + return this; + } + + public EtlPipelineBuilder WithTransformer(IDataTransformer transformer) + { + ArgumentNullException.ThrowIfNull(transformer); + _transformers.Add(transformer); + return this; + } + + public EtlPipelineBuilder WithDestination(IImportDestination destination) + { + _destination = destination ?? throw new ArgumentNullException(nameof(destination)); + return this; + } + + public EtlPipelineBuilder WithPreScript(IScriptRunner script) + { + ArgumentNullException.ThrowIfNull(script); + _preScripts.Add(script); + return this; + } + + public EtlPipelineBuilder WithPostScript(IScriptRunner script) + { + ArgumentNullException.ThrowIfNull(script); + _postScripts.Add(script); + return this; + } + + public EtlPipelineBuilder WithLogger(ILogger logger) + { + _logger = logger; + return this; + } + + public EtlPipeline Build() + { + if (_source == null) + throw new InvalidOperationException("Source is required. Call WithSource() before Build()."); + if (_destination == null) + throw new InvalidOperationException("Destination is required. Call WithDestination() before Build()."); + + return new EtlPipeline( + _name, + _source, + _transformers, + _destination, + _preScripts, + _postScripts, + _logger ?? NullLogger.Instance); + } +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~EtlPipelineTests" --verbosity normal` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/Pipeline/ tests/JdeScoping.DataSync.Tests/Etl/Pipeline/ +git commit -m "feat(etl): implement EtlPipeline and EtlPipelineBuilder" +``` + +--- + +## Phase 7: Configuration and DI + +### Task 13: Add DI Registration + +**Files:** +- Create: `src/JdeScoping.DataSync/Etl/EtlServiceCollectionExtensions.cs` +- Modify: `src/JdeScoping.DataSync/DependencyInjection.cs` + +**Step 1: Create ETL DI extensions** + +```csharp +// src/JdeScoping.DataSync/Etl/EtlServiceCollectionExtensions.cs +using JdeScoping.DataSync.Etl.Pipeline; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.DataSync.Etl; + +/// +/// Extension methods for registering ETL pipeline services. +/// +public static class EtlServiceCollectionExtensions +{ + /// + /// Adds ETL pipeline services to the service collection. + /// + public static IServiceCollection AddEtlPipeline(this IServiceCollection services) + { + // Register factory for creating pipelines + services.AddTransient(); + + return services; + } +} +``` + +**Step 2: Commit** + +```bash +git add src/JdeScoping.DataSync/Etl/EtlServiceCollectionExtensions.cs +git commit -m "feat(etl): add DI registration for ETL pipeline" +``` + +--- + +### Task 14: Run Full Test Suite + +**Step 1: Build the solution** + +Run: `dotnet build NEW/` +Expected: Build succeeds + +**Step 2: Run all DataSync tests** + +Run: `dotnet test tests/JdeScoping.DataSync.Tests --verbosity normal` +Expected: All tests pass + +**Step 3: Commit any fixes if needed** + +--- + +## Summary + +This implementation plan creates a complete ETL pipeline system with: + +1. **Core Interfaces**: `IImportSource`, `IDataTransformer`, `IImportDestination`, `IScriptRunner` +2. **Result Models**: `PipelineResult`, `StepResult`, `DestinationResult` +3. **Transformers**: `TransformingDataReader`, `DataTransformerBase`, `ColumnDropTransformer`, `ColumnRenameTransformer`, `JdeDateTransformer` +4. **Sources**: `DbQuerySource` +5. **Destinations**: `DbBulkImportDestination`, `DbBulkMergeDestination` +6. **Scripts**: `SqlScriptRunner`, `CommonScripts` +7. **Pipeline**: `EtlPipeline`, `EtlPipelineBuilder` + +Each task follows TDD with failing tests first, then implementation, then verification. diff --git a/PLANS/2026-01-03-options-relocation-design.md b/PLANS/2026-01-03-options-relocation-design.md new file mode 100644 index 0000000..cb6ab84 --- /dev/null +++ b/PLANS/2026-01-03-options-relocation-design.md @@ -0,0 +1,116 @@ +# Options Classes Relocation Design + +## Summary + +Move options classes from Core project to the projects that use them, establishing an `Options/` folder convention across all projects. + +## Current State + +All 7 options classes live in `JdeScoping.Core/Options/`: +- `AuthOptions` - used by Api + Infrastructure +- `DataSourceOptions` - used by Infrastructure +- `DataSyncOptions` - duplicate (real one in DataSync) +- `ExcelExportOptions` - duplicate (real one in ExcelIO) +- `LdapOptions` - used by Infrastructure +- `SearchOptions` - unused (future use in DataAccess) +- `SearchProcessingOptions` - used by DataAccess + +## Target State + +### Folder Structure + +``` +src/ +├── JdeScoping.Api/ +│ └── Options/ +│ └── AuthOptions.cs +├── JdeScoping.DataAccess/ +│ └── Options/ +│ ├── SearchOptions.cs +│ └── SearchProcessingOptions.cs +├── JdeScoping.Infrastructure/ +│ └── Options/ +│ ├── DataSourceOptions.cs +│ └── LdapOptions.cs +├── JdeScoping.DataSync/ +│ └── Options/ # Renamed from Configuration/ +│ └── DataSyncOptions.cs +├── JdeScoping.ExcelIO/ +│ └── Options/ # Renamed from Configuration/ +│ └── ExcelExportOptions.cs +└── JdeScoping.Core/ + └── (Options folder deleted) +``` + +### Property Moves + +**LdapOptions** gains properties from AuthOptions: +- `UseFakeAuth` (bool, default: false) +- `AdminBypassUsers` (string[], default: []) + +**AuthOptions** loses those properties, keeping only: +- `CookieName` (string, default: "ScopingTool.Auth") +- `CookieExpirationMinutes` (int, default: 480) + +### Namespace Changes + +| Old Namespace | New Namespace | +|---------------|---------------| +| `JdeScoping.Core.Options.AuthOptions` | `JdeScoping.Api.Options.AuthOptions` | +| `JdeScoping.Core.Options.SearchOptions` | `JdeScoping.DataAccess.Options.SearchOptions` | +| `JdeScoping.Core.Options.SearchProcessingOptions` | `JdeScoping.DataAccess.Options.SearchProcessingOptions` | +| `JdeScoping.Core.Options.DataSourceOptions` | `JdeScoping.Infrastructure.Options.DataSourceOptions` | +| `JdeScoping.Core.Options.LdapOptions` | `JdeScoping.Infrastructure.Options.LdapOptions` | +| `JdeScoping.DataSync.Configuration.*` | `JdeScoping.DataSync.Options.*` | +| `JdeScoping.ExcelIO.Configuration.*` | `JdeScoping.ExcelIO.Options.*` | + +### DI Registration Changes + +**Infrastructure/DependencyInjection.cs:** +- Remove `AuthOptions` registration +- Change `authOptions?.UseFakeAuth` check to use `ldapOptions?.UseFakeAuth` + +**Api/DependencyInjection.cs:** +- Add `AuthOptions` registration + +### Configuration (appsettings.json) + +Move settings from `"Auth"` to `"Ldap"` section: +```json +{ + "Ldap": { + "ServerUrls": ["ldap.corp.example.com"], + "GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com", + "SearchBase": "DC=corp,DC=example,DC=com", + "ConnectionTimeoutSeconds": 30, + "UseFakeAuth": false, + "AdminBypassUsers": [] + }, + "Auth": { + "CookieName": "ScopingTool.Auth", + "CookieExpirationMinutes": 480 + } +} +``` + +## Files to Delete + +- `src/JdeScoping.Core/Options/AuthOptions.cs` +- `src/JdeScoping.Core/Options/DataSourceOptions.cs` +- `src/JdeScoping.Core/Options/DataSyncOptions.cs` +- `src/JdeScoping.Core/Options/ExcelExportOptions.cs` +- `src/JdeScoping.Core/Options/LdapOptions.cs` +- `src/JdeScoping.Core/Options/SearchOptions.cs` +- `src/JdeScoping.Core/Options/SearchProcessingOptions.cs` +- `src/JdeScoping.Core/Options/` (folder) + +## Files to Update + +- `Host/Program.cs` - update using statements +- `Infrastructure/DependencyInjection.cs` - remove AuthOptions, use LdapOptions for UseFakeAuth +- `Infrastructure/Auth/LdapAuthService.cs` - update using, use LdapOptions for AdminBypassUsers +- `Api/DependencyInjection.cs` - add AuthOptions registration +- `DataAccess/DependencyInjection.cs` - update using statements +- `DataSync/**` - rename Configuration namespace to Options +- `ExcelIO/**` - rename Configuration namespace to Options +- `appsettings.json` - move UseFakeAuth/AdminBypassUsers to Ldap section