From 0751fee5f2e8e453d1cbf60ea7afa9ba1425493e Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 30 Jul 2026 14:00:31 +0100 Subject: [PATCH 1/5] Enhance CI/CD, file processor, config, and tests Introduce scheduled tasks install in CI/CD workflows, add file processor setup via config and new types, and update merchant address and estate setup models for consistency. Add event store projection, restore and test PowerShell scripts, and improve Docker/NuGet setup. Refactor code for clarity and reliability. --- .github/workflows/createrelease.yml | 82 +++++++++++++ NuGet.Config | 8 ++ .../AutoScavenge}/Scavenge.xml | Bin .../AutoScavenge}/scavenge.ps1 | 0 .../ReplayParkedQueue/ReplayParkedQueue.ps1 | 6 +- .../Tests/ReplayParkedQueue.Tests.ps1 | 114 ++++++++++++++++++ ...Invoke-DailySettlementProcessing.Tests.ps1 | 68 +++++++++-- .../Tests/Scavenge.Tests.ps1 | 38 ++++-- .../EstateSetupFunctions.cs | 7 +- .../EventStoreFunctions.cs | 2 +- .../Program.cs | 52 +++++++- ...ransactionProcessor.SystemSetupTool.csproj | 4 + .../appsettings.json | 5 +- .../estateconfig/Address.cs | 1 + .../FileProcessingOptions.cs | 47 ++++++++ .../fileprofilesconfig.json | 22 ++++ .../setupconfig.json | 9 +- .../setupconfig.production.json | 72 ++++++----- .../setupconfig.staging.json | 27 +++-- docker-compose-master.yml | 21 ++++ 20 files changed, 510 insertions(+), 75 deletions(-) create mode 100644 NuGet.Config rename {Scripts => ScheduledTasks/AutoScavenge}/Scavenge.xml (100%) rename {Scripts => ScheduledTasks/AutoScavenge}/scavenge.ps1 (100%) create mode 100644 ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 rename {Scripts => ScheduledTasks}/Tests/Scavenge.Tests.ps1 (65%) create mode 100644 TransactionProcessor.SystemSetupTool/fileprofileconfig/FileProcessingOptions.cs create mode 100644 TransactionProcessor.SystemSetupTool/fileprofilesconfig.json diff --git a/.github/workflows/createrelease.yml b/.github/workflows/createrelease.yml index 9c40669..b67e0ae 100644 --- a/.github/workflows/createrelease.yml +++ b/.github/workflows/createrelease.yml @@ -71,6 +71,47 @@ jobs: name: "Deploy to Staging" steps: + - uses: actions/checkout@v4 + + - name: Install scheduled tasks + shell: pwsh + env: + SCHEDULED_TASK_USER: ${{ secrets.SCHEDULED_TASK_USER }} + SCHEDULED_TASK_PASSWORD: ${{ secrets.SCHEDULED_TASK_PASSWORD }} + run: | + $taskScriptDir = 'C:\txnproc\transactionprocessing\powershell' + $scheduledTasksRoot = Join-Path $env:GITHUB_WORKSPACE 'ScheduledTasks' + + New-Item -ItemType Directory -Path $taskScriptDir -Force | Out-Null + + Get-ChildItem -Path $scheduledTasksRoot -Recurse -Filter *.ps1 | + Where-Object { $_.FullName -notmatch '\\Tests\\' } | + Copy-Item -Destination $taskScriptDir -Force + + Get-ChildItem -Path $scheduledTasksRoot -Recurse -Filter *.xml | + Where-Object { $_.FullName -notmatch '\\Tests\\' } | + ForEach-Object { + [xml]$taskDefinition = Get-Content -Path $_.FullName -Raw + $taskName = [string]$taskDefinition.Task.RegistrationInfo.URI + + if ([string]::IsNullOrWhiteSpace($taskName)) { + $taskName = $_.BaseName + } + + $registerParams = @{ + Xml = $taskDefinition.OuterXml + TaskName = $taskName.TrimStart('\') + TaskPath = '\' + Force = $true + } + + if ($env:SCHEDULED_TASK_USER -and $env:SCHEDULED_TASK_PASSWORD) { + $registerParams.User = $env:SCHEDULED_TASK_USER + $registerParams.Password = $env:SCHEDULED_TASK_PASSWORD + } + + Register-ScheduledTask @registerParams | Out-Null + } - name: Download the artifact (Merchant Pos) uses: actions/download-artifact@v4.1.8 @@ -131,6 +172,47 @@ jobs: name: "Deploy to Production" steps: + - uses: actions/checkout@v4 + + - name: Install scheduled tasks + shell: pwsh + env: + SCHEDULED_TASK_USER: ${{ secrets.SCHEDULED_TASK_USER }} + SCHEDULED_TASK_PASSWORD: ${{ secrets.SCHEDULED_TASK_PASSWORD }} + run: | + $taskScriptDir = 'C:\txnproc\transactionprocessing\powershell' + $scheduledTasksRoot = Join-Path $env:GITHUB_WORKSPACE 'ScheduledTasks' + + New-Item -ItemType Directory -Path $taskScriptDir -Force | Out-Null + + Get-ChildItem -Path $scheduledTasksRoot -Recurse -Filter *.ps1 | + Where-Object { $_.FullName -notmatch '\\Tests\\' } | + Copy-Item -Destination $taskScriptDir -Force + + Get-ChildItem -Path $scheduledTasksRoot -Recurse -Filter *.xml | + Where-Object { $_.FullName -notmatch '\\Tests\\' } | + ForEach-Object { + [xml]$taskDefinition = Get-Content -Path $_.FullName -Raw + $taskName = [string]$taskDefinition.Task.RegistrationInfo.URI + + if ([string]::IsNullOrWhiteSpace($taskName)) { + $taskName = $_.BaseName + } + + $registerParams = @{ + Xml = $taskDefinition.OuterXml + TaskName = $taskName.TrimStart('\') + TaskPath = '\' + Force = $true + } + + if ($env:SCHEDULED_TASK_USER -and $env:SCHEDULED_TASK_PASSWORD) { + $registerParams.User = $env:SCHEDULED_TASK_USER + $registerParams.Password = $env:SCHEDULED_TASK_PASSWORD + } + + Register-ScheduledTask @registerParams | Out-Null + } - name: Remove existing Windows service (Merchant Pos) run: | diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..fa626cc --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Scripts/Scavenge.xml b/ScheduledTasks/AutoScavenge/Scavenge.xml similarity index 100% rename from Scripts/Scavenge.xml rename to ScheduledTasks/AutoScavenge/Scavenge.xml diff --git a/Scripts/scavenge.ps1 b/ScheduledTasks/AutoScavenge/scavenge.ps1 similarity index 100% rename from Scripts/scavenge.ps1 rename to ScheduledTasks/AutoScavenge/scavenge.ps1 diff --git a/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 b/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 index 1120f78..aa6fbcd 100644 --- a/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 +++ b/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 @@ -2,10 +2,10 @@ [Parameter(Mandatory = $true)] [string]$BaseUrl, - [Parameter] + [Parameter()] [string]$Username, - [Parameter] + [Parameter()] [string]$Password, [string]$LogDirectory = "C:\home\txnproc\trace", @@ -140,4 +140,4 @@ foreach ($sub in $subscriptions) { #} } -Write-Trace "INFO" "Script completed" \ No newline at end of file +Write-Trace "INFO" "Script completed" diff --git a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 new file mode 100644 index 0000000..6c90e26 --- /dev/null +++ b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 @@ -0,0 +1,114 @@ +Describe 'ReplayParkedQueue.ps1' { + BeforeEach { + Mock Test-Path { $true } + Mock New-Item { } + Mock Get-ChildItem { @() } + Mock Add-Content { } + Mock Remove-Item { } + } + + It 'replays parked messages for subscriptions with parked counts and encodes $all streams' { + $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path + $subscriptions = @( + [pscustomobject]@{ + eventStreamId = 'stream-1' + groupName = 'group-1' + }, + [pscustomobject]@{ + eventStreamId = '$all' + groupName = 'group-2' + } + ) + $captured = [pscustomobject]@{ + SubscriptionCalls = 0 + InfoUris = [System.Collections.Generic.List[string]]::new() + ReplayUris = [System.Collections.Generic.List[string]]::new() + } + + Mock Invoke-RestMethod { + $captured.SubscriptionCalls++ + $subscriptions + } -ParameterFilter { + $Method -eq 'GET' -and + $Uri -eq 'https://queue.example/subscriptions' + } + + Mock Invoke-RestMethod { + $captured.InfoUris.Add($Uri) | Out-Null + @{ parkedMessageCount = 2 } + } -ParameterFilter { + $Method -eq 'GET' -and + $Uri -eq 'https://queue.example/subscriptions/stream-1/group-1/info' + } + + Mock Invoke-RestMethod { + $captured.InfoUris.Add($Uri) | Out-Null + @{ parkedMessageCount = 0 } + } -ParameterFilter { + $Method -eq 'GET' -and + $Uri -eq 'https://queue.example/subscriptions/%24all/group-2/info' + } + + Mock Invoke-RestMethod { + $captured.ReplayUris.Add($Uri) | Out-Null + } -ParameterFilter { + $Method -eq 'POST' -and + $Uri -eq 'https://queue.example/subscriptions/stream-1/group-1/replayParked?from=0' + } + + & $scriptUnderTest 'https://queue.example' 'alice' 'secret' + + $captured.SubscriptionCalls | Should -Be 1 + $captured.InfoUris | Should -Be @( + 'https://queue.example/subscriptions/stream-1/group-1/info', + 'https://queue.example/subscriptions/%24all/group-2/info' + ) + $captured.ReplayUris | Should -Be @( + 'https://queue.example/subscriptions/stream-1/group-1/replayParked?from=0' + ) + } + + It 'does not post a replay when no subscription has parked messages' { + $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path + $subscriptions = @( + [pscustomobject]@{ + eventStreamId = 'stream-1' + groupName = 'group-1' + } + ) + $captured = [pscustomobject]@{ + SubscriptionCalls = 0 + InfoCalls = 0 + ReplayCalls = 0 + } + + Mock Invoke-RestMethod { + $captured.SubscriptionCalls++ + $subscriptions + } -ParameterFilter { + $Method -eq 'GET' -and + $Uri -eq 'https://queue.example/subscriptions' + } + + Mock Invoke-RestMethod { + $captured.InfoCalls++ + @{ parkedMessageCount = 0 } + } -ParameterFilter { + $Method -eq 'GET' -and + $Uri -eq 'https://queue.example/subscriptions/stream-1/group-1/info' + } + + Mock Invoke-RestMethod { + $captured.ReplayCalls++ + } -ParameterFilter { + $Method -eq 'POST' -and + $Uri -like 'https://queue.example/subscriptions/*/*/replayParked?from=0' + } + + & $scriptUnderTest 'https://queue.example' 'alice' 'secret' + + $captured.SubscriptionCalls | Should -Be 1 + $captured.InfoCalls | Should -Be 1 + $captured.ReplayCalls | Should -Be 0 + } +} diff --git a/ScheduledTasks/SettlementProcessing/Tests/Invoke-DailySettlementProcessing.Tests.ps1 b/ScheduledTasks/SettlementProcessing/Tests/Invoke-DailySettlementProcessing.Tests.ps1 index 813e246..7b0a537 100644 --- a/ScheduledTasks/SettlementProcessing/Tests/Invoke-DailySettlementProcessing.Tests.ps1 +++ b/ScheduledTasks/SettlementProcessing/Tests/Invoke-DailySettlementProcessing.Tests.ps1 @@ -1,10 +1,15 @@ Describe 'Invoke-DailySettlementProcessing.ps1' { It 'gets merchants and posts a settlement for each merchant returned' { + $scriptUnderTest = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'Invoke-DailySettlementProcessing.ps1')).Path $postedUris = [System.Collections.Generic.List[string]]::new() $settlementDate = (Get-Date).ToString('yyyy-MM-dd') - $scriptUnderTest = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'Invoke-DailySettlementProcessing.ps1')).Path + $captured = [pscustomobject]@{ + TokenCalls = 0 + MerchantCalls = 0 + } Mock Invoke-RestMethod { + $captured.TokenCalls++ @{ access_token = 'access-token' } @@ -18,6 +23,7 @@ Describe 'Invoke-DailySettlementProcessing.ps1' { } Mock Invoke-RestMethod { + $captured.MerchantCalls++ @{ merchants = @( @{ merchantId = 'merchant-1' }, @@ -45,17 +51,61 @@ Describe 'Invoke-DailySettlementProcessing.ps1' { -ClientId 'client-id' ` -ClientSecret 'client-secret' - Assert-MockCalled Invoke-RestMethod -Times 1 -ParameterFilter { - $Method -eq 'Post' -and $Uri -eq 'https://security.example/connect/token' - } - - Assert-MockCalled Invoke-RestMethod -Times 1 -ParameterFilter { - $Method -eq 'Get' -and $Uri -eq 'https://api.example/api/estates/estate-123/merchants' - } - + $captured.TokenCalls | Should -Be 1 + $captured.MerchantCalls | Should -Be 1 $postedUris | Should -Be @( "https://api.example/api/estates/estate-123/settlements/$settlementDate/merchants/merchant-1", "https://api.example/api/estates/estate-123/settlements/$settlementDate/merchants/merchant-2" ) } + + It 'does not post settlements when no merchants are returned' { + $scriptUnderTest = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'Invoke-DailySettlementProcessing.ps1')).Path + $captured = [pscustomobject]@{ + TokenCalls = 0 + MerchantCalls = 0 + SettlementCalls = 0 + } + + Mock Invoke-RestMethod { + $captured.TokenCalls++ + @{ + access_token = 'access-token' + } + } -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://security.example/connect/token' -and + $ContentType -eq 'application/x-www-form-urlencoded' -and + $Body.grant_type -eq 'client_credentials' -and + $Body.client_id -eq 'client-id' -and + $Body.client_secret -eq 'client-secret' + } + + Mock Invoke-RestMethod { + $captured.MerchantCalls++ + @() + } -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.example/api/estates/estate-123/merchants' -and + $Headers.Authorization -eq 'Bearer access-token' + } + + Mock Invoke-RestMethod { + $captured.SettlementCalls++ + } -ParameterFilter { + $Method -eq 'Post' -and + $Uri -like 'https://api.example/api/estates/estate-123/settlements/*/merchants/*' + } + + & $scriptUnderTest ` + -EstateId 'estate-123' ` + -BaseUrl 'https://api.example/' ` + -SecurityServiceUrl 'https://security.example/' ` + -ClientId 'client-id' ` + -ClientSecret 'client-secret' + + $captured.TokenCalls | Should -Be 1 + $captured.MerchantCalls | Should -Be 1 + $captured.SettlementCalls | Should -Be 0 + } } diff --git a/Scripts/Tests/Scavenge.Tests.ps1 b/ScheduledTasks/Tests/Scavenge.Tests.ps1 similarity index 65% rename from Scripts/Tests/Scavenge.Tests.ps1 rename to ScheduledTasks/Tests/Scavenge.Tests.ps1 index 47957be..ccedb10 100644 --- a/Scripts/Tests/Scavenge.Tests.ps1 +++ b/ScheduledTasks/Tests/Scavenge.Tests.ps1 @@ -1,6 +1,9 @@ -Describe 'scavenge.ps1' { +BeforeAll { + $script:scriptUnderTest = (Resolve-Path (Join-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'AutoScavenge') 'scavenge.ps1')).Path +} + +Describe 'AutoScavenge\scavenge.ps1' { It 'posts to /admin/scavenge after trimming a trailing slash from BaseUrl' { - $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'scavenge.ps1')).Path $expectedUri = 'http://localhost:2113/admin/scavenge' $captured = [pscustomobject]@{ Count = 0 @@ -17,7 +20,7 @@ Describe 'scavenge.ps1' { $Method -eq 'Post' } - & $scriptUnderTest -BaseUrl 'http://localhost:2113/' + & $script:scriptUnderTest -BaseUrl 'http://localhost:2113/' $captured.Count | Should -Be 1 $captured.Uri | Should -Be $expectedUri @@ -27,7 +30,6 @@ Describe 'scavenge.ps1' { } It 'adds a Basic Authorization header when username and password are both provided' { - $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'scavenge.ps1')).Path $expectedUri = 'http://localhost:2113/admin/scavenge' $expectedAuth = 'Basic ' + [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes('alice:secret')) $captured = [pscustomobject]@{ @@ -45,7 +47,7 @@ Describe 'scavenge.ps1' { $Method -eq 'Post' } - & $scriptUnderTest -BaseUrl 'http://localhost:2113' -Username 'alice' -Password 'secret' + & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Username 'alice' -Password 'secret' $captured.Count | Should -Be 1 $captured.Uri | Should -Be $expectedUri @@ -53,7 +55,6 @@ Describe 'scavenge.ps1' { } It 'does not add an Authorization header when only one credential is supplied' { - $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'scavenge.ps1')).Path $captured = [pscustomobject]@{ Count = 0 Uri = $null @@ -69,7 +70,30 @@ Describe 'scavenge.ps1' { $Method -eq 'Post' } - & $scriptUnderTest -BaseUrl 'http://localhost:2113' -Username 'alice' + & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Username 'alice' + + $captured.Count | Should -Be 1 + $captured.Uri | Should -Be 'http://localhost:2113/admin/scavenge' + $captured.Headers.ContainsKey('Authorization') | Should -BeFalse + } + + It 'does not add an Authorization header when only a password is supplied' { + $captured = [pscustomobject]@{ + Count = 0 + Uri = $null + Headers = $null + } + + Mock Invoke-RestMethod { + $captured.Count++ + $captured.Uri = $Uri + $captured.Headers = $Headers + @{ status = 'ok' } + } -ParameterFilter { + $Method -eq 'Post' + } + + & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Password 'secret' $captured.Count | Should -Be 1 $captured.Uri | Should -Be 'http://localhost:2113/admin/scavenge' diff --git a/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs b/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs index 945ddd1..7fcdd37 100644 --- a/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs +++ b/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs @@ -53,9 +53,9 @@ public async Task SetupEstate(CancellationToken cancellationToken) { return ResultHelpers.CreateFailure(createEstateResult); this.EstateId = createEstateResult.Data; - var createUserResult = await this.CreateEstateUser(cancellationToken); - if (createUserResult.IsFailed) - return ResultHelpers.CreateFailure(createUserResult); + //var createUserResult = await this.CreateEstateUser(cancellationToken); + //if (createUserResult.IsFailed) + // return ResultHelpers.CreateFailure(createUserResult); var createOperatorsResult = await this.CreateOperators(cancellationToken); if (createOperatorsResult.IsFailed) @@ -336,6 +336,7 @@ public async Task CreateMerchant(Merchant merchant, CreateMerchantRequest createMerchantRequest = new CreateMerchantRequest { Address = new Address { AddressLine1 = merchant.Address.AddressLine1, Country = merchant.Address.Country, Region = merchant.Address.Region, Town = merchant.Address.Town, + PostalCode = merchant.Address.Postcode }, Name = merchant.Name, Contact = new Contact { ContactName = merchant.Contact.ContactName, EmailAddress = merchant.Contact.EmailAddress }, diff --git a/TransactionProcessor.SystemSetupTool/EventStoreFunctions.cs b/TransactionProcessor.SystemSetupTool/EventStoreFunctions.cs index 71693e6..5ee0b5d 100644 --- a/TransactionProcessor.SystemSetupTool/EventStoreFunctions.cs +++ b/TransactionProcessor.SystemSetupTool/EventStoreFunctions.cs @@ -45,6 +45,7 @@ private async Task SetupSubscriptions(CancellationToken cancellationToke ("$ce-FileAggregate", "Transaction Processor", 0), ("$ce-FileImportLogAggregate", "Transaction Processor", 0), ("$ce-OperatorAggregate", "Transaction Processor", 0), + ("$ce-MerchantBalanceArchive", "Transaction Processor", 0), ("$ce-TransactionAggregate", "Transaction Processor - Domain", 0), ("$ce-SettlementAggregate", "Transaction Processor - Domain", 0), @@ -100,7 +101,6 @@ private async Task DeployProjections(CancellationToken cancellationToken projection.Contains("TransactionProcessorSubscriptionStreamBuilder") || projection.Contains("EstateAggregator") || projection.Contains("MerchantAggregator") || - projection.Contains("MerchantBalanceAggregator") || projection.Contains("CallbackHandlerEnricher")) { continue; diff --git a/TransactionProcessor.SystemSetupTool/Program.cs b/TransactionProcessor.SystemSetupTool/Program.cs index 5ebd498..69aa60e 100644 --- a/TransactionProcessor.SystemSetupTool/Program.cs +++ b/TransactionProcessor.SystemSetupTool/Program.cs @@ -1,9 +1,13 @@ using System; +using System.Data; +using FileProcessor.Client; +using FileProcessor.DataTransferObjects.Requests; using KurrentDB.Client; using SecurityService.DataTransferObjects; using Shared.Results; using Shared.Serialisation; using SimpleResults; +using TransactionProcessor.SystemSetupTool.fileprofileconfig; namespace TransactionProcessor.SystemSetupTool { @@ -21,6 +25,7 @@ namespace TransactionProcessor.SystemSetupTool class Program { private static TransactionProcessorClient TransactionProcessorClient; + private static FileProcessorClient FileProcessorClient; private static SecurityServiceClient SecurityServiceClient; @@ -43,6 +48,7 @@ static async Task Main(string[] args) { Func securityResolver = s => { return ConfigurationReader.GetValue("SecurityServiceUri"); }; Func transactionProcessorResolver = s => { return ConfigurationReader.GetValue("TransactionProcessorApi"); }; + Func fileProcessorResolver = s => { return ConfigurationReader.GetValue("FileProcessorApi"); }; HttpClientHandler handler = new() { ServerCertificateCustomValidationCallback = (message, cert, @@ -56,14 +62,17 @@ static async Task Main(string[] args) { Program.SecurityServiceClient = new SecurityServiceClient(securityResolver, client, Serialise, Deserialise); Program.TransactionProcessorClient = new TransactionProcessorClient(transactionProcessorResolver, client, Serialise, Deserialise); + Program.FileProcessorClient = new FileProcessorClient(fileProcessorResolver, client, Serialise, Deserialise); + KurrentDBClientSettings settings = KurrentDBClientSettings.Create(ConfigurationReader.GetValue("EventStoreAddress")); Program.ProjectionClient = new (settings); Program.PersistentSubscriptionsClient = new (settings); - Mode setupMode = Mode.EstateSetup; + Mode setupMode = Mode.FileProcessorSetup; - String configFileName = "setupconfig.json"; + String configFileName = "setupconfig.staging.json"; + FileProcessingOptions fileProcessingOptions = await Program.GetFileProfileConfig(cancellationToken); IdentityServerConfiguration identityServerConfiguration = await Program.GetIdentityServerConfig(cancellationToken); IdentityServerFunctions identityServerFunctions = new(Program.SecurityServiceClient, identityServerConfiguration); EventStoreFunctions eventStoreFunctions = new(Program.ProjectionClient, Program.PersistentSubscriptionsClient); @@ -72,6 +81,7 @@ static async Task Main(string[] args) { Mode.SecuritySetup => await identityServerFunctions.CreateConfig(cancellationToken), Mode.EventStoreSetup => await eventStoreFunctions.SetupEventStore(cancellationToken), Mode.EstateSetup => await SetupEstates(configFileName,cancellationToken), + Mode.FileProcessorSetup => await SetupFileProcessors(fileProcessingOptions, cancellationToken), _ => Result.Invalid($"Invalid mode {setupMode}") }; @@ -83,6 +93,29 @@ static async Task Main(string[] args) { } } + private static async Task SetupFileProcessors(FileProcessingOptions fileProcessingOptions, + CancellationToken cancellationToken) { + Result tokenResult = await SecurityServiceClient.GetToken("serviceClient", "d192cbc46d834d0da90e8a9d50ded543", CancellationToken.None); + if (tokenResult.IsFailed) + return ResultHelpers.CreateFailure(tokenResult); + foreach (FileProfile fileProfile in fileProcessingOptions.FileProfiles) { + CreateFileProfileRequest createFileProfileRequest = new() + { + FileProfileId = fileProfile.Id, + Name = fileProfile.Name, + ListeningDirectory = fileProfile.ListeningDirectory, + RequestType = fileProfile.RequestType, + OperatorName = fileProfile.OperatorName, + LineTerminator = Enum.Parse(fileProfile.LineTerminator), + FileFormatHandler = fileProfile.FileFormatHandler + }; + Result result = await Program.FileProcessorClient.CreateFileProfile(tokenResult.Data.AccessToken, createFileProfileRequest, cancellationToken); + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + } + return Result.Success(); + } + static String Serialise(Object arg) { return StringSerialiser.Serialise(arg, new SerialiserOptions(SerialiserPropertyFormat.SnakeCase)); @@ -108,7 +141,8 @@ private static async Task SetupEstates(String configFileName, Cancellati public enum Mode { SecuritySetup, EventStoreSetup, - EstateSetup + EstateSetup, + FileProcessorSetup, } private static async Task GetIdentityServerConfig(CancellationToken cancellationToken) { @@ -123,6 +157,18 @@ private static async Task GetIdentityServerConfig(C return identityServerConfiguration; } + private static async Task GetFileProfileConfig(CancellationToken cancellationToken) + { + // Read the file profile config json string + String fileProfileJsonData; + using StreamReader sr = new("fileprofilesconfig.json"); + fileProfileJsonData = await sr.ReadToEndAsync(cancellationToken); + + FileProcessingOptions fileProcessingOptions = StringSerialiser.Deserialise(fileProfileJsonData); + + return fileProcessingOptions; + } + private static async Task GetEstatesConfig(String configFileName, CancellationToken cancellationToken) { // Read the estate config json string diff --git a/TransactionProcessor.SystemSetupTool/TransactionProcessor.SystemSetupTool.csproj b/TransactionProcessor.SystemSetupTool/TransactionProcessor.SystemSetupTool.csproj index ccda701..1950022 100644 --- a/TransactionProcessor.SystemSetupTool/TransactionProcessor.SystemSetupTool.csproj +++ b/TransactionProcessor.SystemSetupTool/TransactionProcessor.SystemSetupTool.csproj @@ -21,6 +21,7 @@ + @@ -57,6 +58,9 @@ Always + + PreserveNewest + Always diff --git a/TransactionProcessor.SystemSetupTool/appsettings.json b/TransactionProcessor.SystemSetupTool/appsettings.json index 7deaa3b..fde9f02 100644 --- a/TransactionProcessor.SystemSetupTool/appsettings.json +++ b/TransactionProcessor.SystemSetupTool/appsettings.json @@ -3,16 +3,19 @@ // Local (Docker) //"SecurityServiceUri": "https://127.0.0.1:5001", //"TransactionProcessorApi": "http://127.0.0.1:5002", + //"FileProcessorApi": "http://127.0.0.1:5009", //"EventStoreAddress": "esdb://admin:changeit@127.0.0.1:4113?tls=false&tlsVerifyCert=false" // Staging "SecurityServiceUri": "https://192.168.1.163:5001", "TransactionProcessorApi": "http://192.168.1.163:5002", - "EventStoreAddress": "esdb://admin:changeit@192.168.1.163:2113?tls=false&tlsVerifyCert=false" + "FileProcessorApi": "http://192.168.1.163:5009", + "EventStoreAddress": "esdb://admin:changeit@192.168.1.163:2113?tls=false&tlsVerifyCert=false" // Production //"SecurityServiceUri": "https://192.168.1.155:5001", //"TransactionProcessorApi": "http://192.168.1.155:5002", + //"FileProcessorApi": "http://192.168.1.155:5009", //"EventStoreAddress": "esdb://admin:changeit@192.168.1.155:2113?tls=false&tlsVerifyCert=false" } } diff --git a/TransactionProcessor.SystemSetupTool/estateconfig/Address.cs b/TransactionProcessor.SystemSetupTool/estateconfig/Address.cs index 811c2b1..c2902f1 100644 --- a/TransactionProcessor.SystemSetupTool/estateconfig/Address.cs +++ b/TransactionProcessor.SystemSetupTool/estateconfig/Address.cs @@ -11,5 +11,6 @@ public class Address public string Region { get; set; } public string Town { get; set; } + public string Postcode { get; set; } } } \ No newline at end of file diff --git a/TransactionProcessor.SystemSetupTool/fileprofileconfig/FileProcessingOptions.cs b/TransactionProcessor.SystemSetupTool/fileprofileconfig/FileProcessingOptions.cs new file mode 100644 index 0000000..1b990c7 --- /dev/null +++ b/TransactionProcessor.SystemSetupTool/fileprofileconfig/FileProcessingOptions.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; + +namespace TransactionProcessor.SystemSetupTool.fileprofileconfig; + +public sealed class FileProcessingOptions +{ + public List FileProfiles { get; init; } = []; +} + +public sealed class FileProfile +{ + /// + /// Unique identifier for the file profile. + /// + public Guid Id { get; init; } + + /// + /// Friendly name of the profile. + /// + public required string Name { get; init; } + + /// + /// Directory to monitor for incoming files. + /// + public required string ListeningDirectory { get; init; } + + /// + /// Request type that should be created for each record. + /// + public required string RequestType { get; init; } + + /// + /// Name of the operator associated with the profile. + /// + public required string OperatorName { get; init; } + + /// + /// Line terminator used in the file (e.g. "\n" or "\r\n"). + /// + public required string LineTerminator { get; init; } + + /// + /// Name of the class responsible for parsing the file. + /// + public required string FileFormatHandler { get; init; } +} \ No newline at end of file diff --git a/TransactionProcessor.SystemSetupTool/fileprofilesconfig.json b/TransactionProcessor.SystemSetupTool/fileprofilesconfig.json new file mode 100644 index 0000000..c71e20e --- /dev/null +++ b/TransactionProcessor.SystemSetupTool/fileprofilesconfig.json @@ -0,0 +1,22 @@ +{ + "file_profiles": [ + { + "id": "B2A59ABF-293D-4A6B-B81B-7007503C3476", + "name": "Safaricom Topup", + "listening_directory": "/home/txnproc/bulkfiles/safaricom", + "request_type": "SafaricomTopupRequest", + "operator_name": "Safaricom", + "line_terminator": "LineFeed", + "file_format_handler": "SafaricomFileFormatHandler" + }, + { + "id": "8806EDBC-3ED6-406B-9E5F-A9078356BE99", + "name": "Voucher Issue", + "listening_directory": "/home/txnproc/bulkfiles/voucher", + "request_type": "VoucherRequest", + "operator_name": "Voucher", + "line_terminator": "LineFeed", + "file_format_handler": "VoucherFileFormatHandler" + } + ] +} diff --git a/TransactionProcessor.SystemSetupTool/setupconfig.json b/TransactionProcessor.SystemSetupTool/setupconfig.json index ccc0f77..5247308 100644 --- a/TransactionProcessor.SystemSetupTool/setupconfig.json +++ b/TransactionProcessor.SystemSetupTool/setupconfig.json @@ -15,12 +15,13 @@ { "merchant_id": "AB1C99FB-1C6C-4694-9A32-B71BE5D1DA33", "name": "Test Merchant 1", - "settlementschedule": "Weekly", + "settlement_schedule": "Weekly", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Test Contact", @@ -42,7 +43,7 @@ "device": { "device_identifier": "testmerchant1device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" } ], "operators": [ diff --git a/TransactionProcessor.SystemSetupTool/setupconfig.production.json b/TransactionProcessor.SystemSetupTool/setupconfig.production.json index 4c4a3a1..c310110 100644 --- a/TransactionProcessor.SystemSetupTool/setupconfig.production.json +++ b/TransactionProcessor.SystemSetupTool/setupconfig.production.json @@ -15,12 +15,13 @@ { "merchant_id": "AB1C99FB-1C6C-4694-9A32-B71BE5D1DA33", "name": "Live Merchant 1", - "settlementschedule": "Weekly", + "settlement_schedule": "Weekly", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -42,17 +43,18 @@ "device": { "device_identifier": "livemerchant1device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "AF4D7C7C-9B8D-4E58-A12A-28D3E0B89DF6", "name": "Live Merchant 2", - "settlementschedule": "Monthly", + "settlement_schedule": "Monthly", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -74,17 +76,18 @@ "device": { "device_identifier": "livemerchant2device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "8BC8434D-41F9-4CC3-83BC-E73F20C02E1D", "name": "Live Merchant 3", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -106,17 +109,18 @@ "device": { "device_identifier": "livemerchant3device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "22549CD1-AFDD-44F9-882F-51C3EA71CCC0", "name": "Live Merchant 4", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -138,17 +142,18 @@ "device": { "device_identifier": "livemerchant4device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "115EF339-6FB2-43E8-9C7F-7F270059AD08", "name": "Live Merchant 5", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -170,17 +175,18 @@ "device": { "device_identifier": "livemerchant5device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "E8064ED9-BEDC-4159-9708-888BDE070BE4", "name": "Live Merchant 6", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -202,17 +208,18 @@ "device": { "device_identifier": "livemerchant6device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "870D41FA-FD73-40B8-A8C2-A2673D6C7428", "name": "Live Merchant 7", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -234,17 +241,18 @@ "device": { "device_identifier": "livemerchant7device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "279502C6-EA18-4E5A-A678-5F1CA044E828", "name": "Live Merchant 8", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Live Contact", @@ -266,7 +274,7 @@ "device": { "device_identifier": "livemerchant8device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" } ], diff --git a/TransactionProcessor.SystemSetupTool/setupconfig.staging.json b/TransactionProcessor.SystemSetupTool/setupconfig.staging.json index e9801ef..ccbd7dd 100644 --- a/TransactionProcessor.SystemSetupTool/setupconfig.staging.json +++ b/TransactionProcessor.SystemSetupTool/setupconfig.staging.json @@ -15,12 +15,13 @@ { "merchant_id": "AB1C99FB-1C6C-4694-9A32-B71BE5D1DA33", "name": "Staging Merchant 1", - "settlementschedule": "Weekly", + "settlement_schedule": "Weekly", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Staging Contact", @@ -42,17 +43,18 @@ "device": { "device_identifier": "stagingmerchant1device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "AF4D7C7C-9B8D-4E58-A12A-28D3E0B89DF6", "name": "Staging Merchant 2", - "settlementschedule": "Monthly", + "settlement_schedule": "Monthly", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Staging Contact", @@ -74,17 +76,18 @@ "device": { "device_identifier": "stagingmerchant2device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" }, { "merchant_id": "8BC8434D-41F9-4CC3-83BC-E73F20C02E1D", "name": "Staging Merchant 3", - "settlementschedule": "Immediate", + "settlement_schedule": "Immediate", "address": { - "address_line_1": "test address line 1", + "address_line1": "test address line 1", "country": "United Kingdom", "region": "Region", - "town": "MyTown" + "town": "MyTown", + "postcode": "AB12 3CD" }, "contact": { "contact_name": "Staging Contact", @@ -106,7 +109,7 @@ "device": { "device_identifier": "stagingmerchant3device" }, - "createdate": "2022-08-31" + "create_date": "2022-08-31" } ], "operators": [ diff --git a/docker-compose-master.yml b/docker-compose-master.yml index e3d3e49..fe32d67 100644 --- a/docker-compose-master.yml +++ b/docker-compose-master.yml @@ -119,6 +119,7 @@ services: environment: - ConnectionStrings:TestBankReadModel=server=sqlserver;user id=sa;password=thisisalongpassword123!;database=TestBankReadModel;Encrypt=false - ConnectionStrings:PataPawaReadModel=server=sqlserver;user id=sa;password=thisisalongpassword123!;database=PataPawaReadModel;Encrypt=false + - ConnectionStrings:AgencyBankingReadModel=server=sqlserver;user id=sa;password=thisisalongpassword123!;database=AgencyBankingReadModel;Encrypt=false ports: - "9000:9000" restart: on-failure @@ -165,3 +166,23 @@ services: - EventStoreSettings:Insecure=true depends_on: - kurrentdb + + estatereporting: + image: stuartferguson/estatereportingapi + container_name: estatereporting_master + volumes: + - E:\home\txnproc\docker\trace:/home/txnproc/trace + ports: + - "5011:5011" + environment: + - EventStoreSettings:ConnectionString=esdb://admin:changeit@kurrentdb:2113?tls=false&tlsVerifyCert=false + - EventStoreSettings:Insecure=true + - AppSettings:SecurityService=https://securityservice:5001 + - SecurityConfiguration:Authority=https://securityservice:5001 + - urls=http://*:5011 + - ConnectionStrings:TransactionProcessorReadModel=server=sqlserver;user id=sa;password=thisisalongpassword123!;database=TransactionProcessorReadModel;Encrypt=false + - ConnectionStrings:HealthCheck=server=sqlserver;database=master;user id=sa;password=thisisalongpassword123!;Encrypt=false + depends_on: + - sqlserver + - kurrentdb + - securityservice From 5025e16646a45b9d2a0903c14eb1db9afa435d84 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 30 Jul 2026 14:05:02 +0100 Subject: [PATCH 2/5] pull request tests tweak --- .github/workflows/powershell_nightly.yml | 6 +++++- .github/workflows/pullrequest.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/powershell_nightly.yml b/.github/workflows/powershell_nightly.yml index f9887bd..4e091cb 100644 --- a/.github/workflows/powershell_nightly.yml +++ b/.github/workflows/powershell_nightly.yml @@ -24,4 +24,8 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Pester -Path ./ScheduledTasks/SettlementProcessing/Scripts/Tests -CI + Invoke-Pester -Path @( + './ScheduledTasks/Tests' + './ScheduledTasks/ReplayParkedQueue/Tests' + './ScheduledTasks/SettlementProcessing/Tests' + ) -CI diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 33fab4d..40f6d6d 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -53,4 +53,8 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Pester -Path ./ScheduledTasks/SettlementProcessing/Tests -CI + Invoke-Pester -Path @( + './ScheduledTasks/Tests' + './ScheduledTasks/ReplayParkedQueue/Tests' + './ScheduledTasks/SettlementProcessing/Tests' + ) -CI From 2462e517e5e505098a9add98e1dced08c70f995c Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 30 Jul 2026 14:23:44 +0100 Subject: [PATCH 3/5] Fix replay queue test filesystem mocking --- .../Tests/ReplayParkedQueue.Tests.ps1 | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 index 6c90e26..5fa8380 100644 --- a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 +++ b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 @@ -1,14 +1,7 @@ Describe 'ReplayParkedQueue.ps1' { - BeforeEach { - Mock Test-Path { $true } - Mock New-Item { } - Mock Get-ChildItem { @() } - Mock Add-Content { } - Mock Remove-Item { } - } - It 'replays parked messages for subscriptions with parked counts and encodes $all streams' { $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path + $logDirectory = Join-Path $TestDrive 'replay-logs-1' $subscriptions = @( [pscustomobject]@{ eventStreamId = 'stream-1' @@ -56,7 +49,7 @@ Describe 'ReplayParkedQueue.ps1' { $Uri -eq 'https://queue.example/subscriptions/stream-1/group-1/replayParked?from=0' } - & $scriptUnderTest 'https://queue.example' 'alice' 'secret' + & $scriptUnderTest 'https://queue.example' 'alice' 'secret' -LogDirectory $logDirectory $captured.SubscriptionCalls | Should -Be 1 $captured.InfoUris | Should -Be @( @@ -70,6 +63,7 @@ Describe 'ReplayParkedQueue.ps1' { It 'does not post a replay when no subscription has parked messages' { $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path + $logDirectory = Join-Path $TestDrive 'replay-logs-2' $subscriptions = @( [pscustomobject]@{ eventStreamId = 'stream-1' @@ -105,7 +99,7 @@ Describe 'ReplayParkedQueue.ps1' { $Uri -like 'https://queue.example/subscriptions/*/*/replayParked?from=0' } - & $scriptUnderTest 'https://queue.example' 'alice' 'secret' + & $scriptUnderTest 'https://queue.example' 'alice' 'secret' -LogDirectory $logDirectory $captured.SubscriptionCalls | Should -Be 1 $captured.InfoCalls | Should -Be 1 From 7e353d57f96635cd19ea4ab8304101df4c3fea65 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 30 Jul 2026 14:32:55 +0100 Subject: [PATCH 4/5] Use PSCredential for replay queue auth --- .../ReplayParkedQueue/ReplayParkedQueue.ps1 | 16 ++++++----- .../Tests/ReplayParkedQueue.Tests.ps1 | 27 +++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 b/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 index aa6fbcd..dda6cf4 100644 --- a/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 +++ b/ScheduledTasks/ReplayParkedQueue/ReplayParkedQueue.ps1 @@ -3,10 +3,7 @@ [string]$BaseUrl, [Parameter()] - [string]$Username, - - [Parameter()] - [string]$Password, + [System.Management.Automation.PSCredential]$Credential, [string]$LogDirectory = "C:\home\txnproc\trace", [int]$LogRetentionDays = 7 @@ -63,10 +60,15 @@ Write-Trace "INFO" "Script started. BaseUrl=$BaseUrl" # ========================= # Auth header # ========================= -$AuthHeader = @{ - Authorization = "Basic " + +$AuthHeader = @{} +if ($null -ne $Credential) { + $networkCredential = $Credential.GetNetworkCredential() + $username = $networkCredential.UserName + $password = $networkCredential.Password + + $AuthHeader.Authorization = "Basic " + [Convert]::ToBase64String( - [Text.Encoding]::ASCII.GetBytes("$Username`:$Password") + [Text.Encoding]::ASCII.GetBytes("$username`:$password") ) } diff --git a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 index 5fa8380..064f212 100644 --- a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 +++ b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 @@ -2,6 +2,7 @@ Describe 'ReplayParkedQueue.ps1' { It 'replays parked messages for subscriptions with parked counts and encodes $all streams' { $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path $logDirectory = Join-Path $TestDrive 'replay-logs-1' + $credential = [pscredential]::new('alice', (ConvertTo-SecureString 'secret' -AsPlainText -Force)) $subscriptions = @( [pscustomobject]@{ eventStreamId = 'stream-1' @@ -49,9 +50,10 @@ Describe 'ReplayParkedQueue.ps1' { $Uri -eq 'https://queue.example/subscriptions/stream-1/group-1/replayParked?from=0' } - & $scriptUnderTest 'https://queue.example' 'alice' 'secret' -LogDirectory $logDirectory + & $scriptUnderTest 'https://queue.example' -Credential $credential -LogDirectory $logDirectory $captured.SubscriptionCalls | Should -Be 1 + $captured.InfoUris.Count | Should -Be 2 $captured.InfoUris | Should -Be @( 'https://queue.example/subscriptions/stream-1/group-1/info', 'https://queue.example/subscriptions/%24all/group-2/info' @@ -64,6 +66,7 @@ Describe 'ReplayParkedQueue.ps1' { It 'does not post a replay when no subscription has parked messages' { $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path $logDirectory = Join-Path $TestDrive 'replay-logs-2' + $credential = [pscredential]::new('alice', (ConvertTo-SecureString 'secret' -AsPlainText -Force)) $subscriptions = @( [pscustomobject]@{ eventStreamId = 'stream-1' @@ -99,10 +102,30 @@ Describe 'ReplayParkedQueue.ps1' { $Uri -like 'https://queue.example/subscriptions/*/*/replayParked?from=0' } - & $scriptUnderTest 'https://queue.example' 'alice' 'secret' -LogDirectory $logDirectory + & $scriptUnderTest 'https://queue.example' -Credential $credential -LogDirectory $logDirectory $captured.SubscriptionCalls | Should -Be 1 $captured.InfoCalls | Should -Be 1 $captured.ReplayCalls | Should -Be 0 } + + It 'does not add an Authorization header when no credential is supplied' { + $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path + $logDirectory = Join-Path $TestDrive 'replay-logs-3' + $captured = [pscustomobject]@{ + Headers = $null + } + + Mock Invoke-RestMethod { + $captured.Headers = $Headers + @() + } -ParameterFilter { + $Method -eq 'GET' -and + $Uri -eq 'https://queue.example/subscriptions' + } + + & $scriptUnderTest 'https://queue.example' -LogDirectory $logDirectory + + $captured.Headers.ContainsKey('Authorization') | Should -BeFalse + } } From 6c5b27d198eb721558674d5d92822dc98159f929 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 30 Jul 2026 14:39:35 +0100 Subject: [PATCH 5/5] Remove plaintext secure string from replay test --- .../Tests/ReplayParkedQueue.Tests.ps1 | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 index 064f212..f17cf70 100644 --- a/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 +++ b/ScheduledTasks/ReplayParkedQueue/Tests/ReplayParkedQueue.Tests.ps1 @@ -2,7 +2,11 @@ Describe 'ReplayParkedQueue.ps1' { It 'replays parked messages for subscriptions with parked counts and encodes $all streams' { $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path $logDirectory = Join-Path $TestDrive 'replay-logs-1' - $credential = [pscredential]::new('alice', (ConvertTo-SecureString 'secret' -AsPlainText -Force)) + $securePassword = [System.Security.SecureString]::new() + foreach ($code in 115, 101, 99, 114, 101, 116) { + $securePassword.AppendChar([char]$code) + } + $credential = [pscredential]::new('alice', $securePassword) $subscriptions = @( [pscustomobject]@{ eventStreamId = 'stream-1' @@ -66,7 +70,11 @@ Describe 'ReplayParkedQueue.ps1' { It 'does not post a replay when no subscription has parked messages' { $scriptUnderTest = (Resolve-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'ReplayParkedQueue.ps1')).Path $logDirectory = Join-Path $TestDrive 'replay-logs-2' - $credential = [pscredential]::new('alice', (ConvertTo-SecureString 'secret' -AsPlainText -Force)) + $securePassword = [System.Security.SecureString]::new() + foreach ($code in 115, 101, 99, 114, 101, 116) { + $securePassword.AppendChar([char]$code) + } + $credential = [pscredential]::new('alice', $securePassword) $subscriptions = @( [pscustomobject]@{ eventStreamId = 'stream-1'