From d431ff9660a88b3c647fe5a4df8154d440b9d559 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 12:10:40 -0400 Subject: [PATCH] Fix dashboard static assets and add client e2e scripts --- docs/GatewayTesting.md | 47 ++ docs/design-decisions.md | 3 +- docs/gateway-dashboard-design.md | 7 +- docs/gateway-process-design.md | 4 +- scripts/discover-testmachine-tags.ps1 | 126 +++++ scripts/run-client-e2e-tests.ps1 | 530 ++++++++++++++++++ .../Configuration/DashboardOptions.cs | 2 +- .../Dashboard/Components/App.razor | 2 +- .../Components/Pages/DashboardHome.razor | 1 + .../Components/Pages/EventsPage.razor | 1 + .../Components/Pages/SessionDetailsPage.razor | 1 + .../Components/Pages/SessionsPage.razor | 1 + .../Components/Pages/SettingsPage.razor | 1 + .../Components/Pages/WorkersPage.razor | 1 + src/MxGateway.Server/GatewayApplication.cs | 74 ++- src/MxGateway.Server/appsettings.json | 2 +- .../Configuration/GatewayOptionsTests.cs | 2 +- 17 files changed, 794 insertions(+), 11 deletions(-) create mode 100644 scripts/discover-testmachine-tags.ps1 create mode 100644 scripts/run-client-e2e-tests.ps1 diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index e02b562..b250df4 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -74,6 +74,53 @@ The test output includes session id, worker process id, command status, HRESULT/status diagnostics, event sequence and handles, close status, and worker stdout/stderr lines emitted during the run. +## Client E2E Scripts + +`scripts/discover-testmachine-tags.ps1` queries the ZB Galaxy Repository for the +deployed runtime references used by the live client e2e scripts. It reads +`TestMachine_001` through `TestMachine_020` and the expected attributes: + +- `ProtectedValue` +- `TestChangingInt` +- `TestBoolArray` +- `TestIntArray` +- `TestDateTimeArray` +- `TestStringArray` + +The discovery output includes the exact `fullTagReference`, data type, array +dimension, and security classification. The array attributes are expected to be +dimension 50. `ProtectedValue` has security classification 2 and requires +secured write semantics; the current client CLI e2e runner subscribes to it but +does not attempt a normal `Write`. + +Run discovery directly when validating the Galaxy Repository inputs: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/discover-testmachine-tags.ps1 -Json +``` + +`scripts/run-client-e2e-tests.ps1` drives the .NET, Go, Rust, Python, and Java +client CLIs through a live gateway session. For each client it opens one +session, registers, adds and advises every discovered test tag, reads a bounded +event stream, then closes the session in a `finally` path. The script writes a +JSON report under `artifacts/e2e/`. + +Build the gateway and worker, start the gateway, and provide a valid API key +before running the client e2e script: + +```powershell +$env:MXGATEWAY_API_KEY = "" +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 +``` + +Useful runner options: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Clients dotnet,python -MachineStart 1 -MachineEnd 2 +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipStream +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Endpoint localhost:5000 -ApiKeyEnv MXGATEWAY_API_KEY +``` + ## Focused Commands Run the cross-language smoke matrix tests after changing the documented client diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 01e78bc..34aa1cc 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -296,7 +296,8 @@ Do not use MudBlazor or other Blazor UI component libraries for v1. Dashboard access should require API-key-backed dashboard authentication with `admin` scope when enabled. For local development, anonymous localhost access -may exist only behind an explicit configuration option that defaults to false. +is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is +limited to loopback requests. ## Later Revisit Items diff --git a/docs/gateway-dashboard-design.md b/docs/gateway-dashboard-design.md index 0fcd658..ab1c14f 100644 --- a/docs/gateway-dashboard-design.md +++ b/docs/gateway-dashboard-design.md @@ -275,8 +275,9 @@ The implementation path is: 5. Dashboard pages require that cookie. 6. Logout clears the cookie. -For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost` -option. It must default to false. +For local development, `Dashboard:AllowAnonymousLocalhost` defaults to `true`. +The bypass applies only to loopback requests; remote dashboard requests still +use the API-key-backed cookie flow. `DashboardAuthenticator` keeps API-key validation outside UI components. It formats the submitted key as a bearer authorization header for @@ -296,7 +297,7 @@ Suggested configuration: "Enabled": true, "PathBase": "/dashboard", "RequireAdminScope": true, - "AllowAnonymousLocalhost": false, + "AllowAnonymousLocalhost": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index ec69253..0808232 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -682,7 +682,7 @@ SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears that cookie. Login and logout posts use anti-forgery validation, and dashboard API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost` allows only loopback requests to bypass the dashboard cookie requirement and -defaults to `false`. +defaults to `true`. Recommended scopes: @@ -861,7 +861,7 @@ Suggested configuration shape: "Enabled": true, "PathBase": "/dashboard", "RequireAdminScope": true, - "AllowAnonymousLocalhost": false, + "AllowAnonymousLocalhost": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, diff --git a/scripts/discover-testmachine-tags.ps1 b/scripts/discover-testmachine-tags.ps1 new file mode 100644 index 0000000..203a89e --- /dev/null +++ b/scripts/discover-testmachine-tags.ps1 @@ -0,0 +1,126 @@ +[CmdletBinding()] +param( + [int]$MachineStart = 1, + [int]$MachineEnd = 20, + [string[]]$Attributes = @( + "ProtectedValue", + "TestChangingInt", + "TestBoolArray", + "TestIntArray", + "TestDateTimeArray", + "TestStringArray" + ), + [string]$SqlServer = "localhost", + [string]$Database = "ZB", + [string]$SqlcmdPath = "C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\130\Tools\Binn\SQLCMD.EXE", + [switch]$Json +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ($MachineStart -lt 1 -or $MachineEnd -lt $MachineStart) { + throw "MachineStart must be at least 1 and MachineEnd must be greater than or equal to MachineStart." +} + +$Attributes = @($Attributes | ForEach-Object { + $_ -split "," +} | ForEach-Object { + $_.Trim() +} | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) +}) + +if ($Attributes.Count -eq 0) { + throw "At least one attribute name is required." +} + +if (-not (Test-Path -LiteralPath $SqlcmdPath)) { + throw "sqlcmd was not found at '$SqlcmdPath'. Pass -SqlcmdPath or update docs/toolchain-links.md." +} + +$attributeList = ($Attributes | ForEach-Object { + "'" + $_.Replace("'", "''") + "'" +}) -join "," + +$query = @" +SET NOCOUNT ON; +;WITH deployed_package_chain AS ( + SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth + FROM gobject g + INNER JOIN package p ON p.package_id = g.deployed_package_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 + UNION ALL + SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 + FROM deployed_package_chain dpc + INNER JOIN package p ON p.package_id = dpc.derived_from_package_id + WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 +), ranked_dynamic AS ( + SELECT + dpc.gobject_id, + g.tag_name, + da.attribute_name, + g.tag_name + '.' + da.attribute_name + + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference, + da.mx_data_type, + dt.description AS data_type_name, + da.is_array, + CASE WHEN da.is_array = 1 + THEN CONVERT(int, CONVERT(varbinary(2), + SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) + ELSE NULL + END AS array_dimension, + da.security_classification, + ROW_NUMBER() OVER ( + PARTITION BY dpc.gobject_id, da.attribute_name + ORDER BY dpc.depth + ) AS rn + FROM deployed_package_chain dpc + INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id + INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id + LEFT JOIN data_type dt ON dt.mx_data_type = da.mx_data_type + WHERE g.tag_name LIKE 'TestMachine[_][0-9][0-9][0-9]' + AND TRY_CONVERT(int, RIGHT(g.tag_name, 3)) BETWEEN $MachineStart AND $MachineEnd + AND da.attribute_name IN ($attributeList) +) +SELECT tag_name, attribute_name, full_tag_reference, mx_data_type, data_type_name, + is_array, COALESCE(CONVERT(varchar(12), array_dimension), '') AS array_dimension, + security_classification +FROM ranked_dynamic +WHERE rn = 1 +ORDER BY tag_name, attribute_name; +"@ + +$output = & $SqlcmdPath -S $SqlServer -d $Database -E -W -h -1 -s "|" -Q $query +if ($LASTEXITCODE -ne 0) { + throw "Galaxy Repository tag discovery failed with exit code $LASTEXITCODE." +} + +$rows = foreach ($line in $output) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + $parts = $line -split "\|", 8 + if ($parts.Count -ne 8) { + continue + } + + [pscustomobject]@{ + tagName = $parts[0] + attributeName = $parts[1] + fullTagReference = $parts[2] + mxDataType = [int]$parts[3] + dataTypeName = $parts[4] + isArray = $parts[5] -eq "1" + arrayDimension = if ([string]::IsNullOrWhiteSpace($parts[6])) { $null } else { [int]$parts[6] } + securityClassification = [int]$parts[7] + } +} + +if ($Json) { + $rows | ConvertTo-Json -Depth 4 + return +} + +$rows | Format-Table -AutoSize diff --git a/scripts/run-client-e2e-tests.ps1 b/scripts/run-client-e2e-tests.ps1 new file mode 100644 index 0000000..659ecf1 --- /dev/null +++ b/scripts/run-client-e2e-tests.ps1 @@ -0,0 +1,530 @@ +[CmdletBinding()] +param( + [string[]]$Clients = @("dotnet", "go", "rust", "python", "java"), + [int]$MachineStart = 1, + [int]$MachineEnd = 20, + [string[]]$Attributes = @( + "ProtectedValue", + "TestChangingInt", + "TestBoolArray", + "TestIntArray", + "TestDateTimeArray", + "TestStringArray" + ), + [string]$Endpoint = "localhost:5000", + [string]$ApiKeyEnv = "MXGATEWAY_API_KEY", + [string]$SqlServer = "localhost", + [string]$Database = "ZB", + [int]$EventLimit = 5, + [switch]$SkipStream, + [switch]$DryRun, + [string]$ReportPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$discoveryScript = Join-Path $PSScriptRoot "discover-testmachine-tags.ps1" +$validClients = @("dotnet", "go", "rust", "python", "java") +$Clients = @($Clients | ForEach-Object { + $_ -split "," +} | ForEach-Object { + $_.Trim().ToLowerInvariant() +} | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) +}) +$Attributes = @($Attributes | ForEach-Object { + $_ -split "," +} | ForEach-Object { + $_.Trim() +} | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) +}) + +if ($Clients.Count -eq 0) { + throw "At least one client is required." +} + +if ($Attributes.Count -eq 0) { + throw "At least one attribute is required." +} + +foreach ($client in $Clients) { + if ($validClients -notcontains $client) { + throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')." + } +} + +if ([string]::IsNullOrWhiteSpace($ReportPath)) { + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $ReportPath = Join-Path $repoRoot "artifacts/e2e/testmachine-client-e2e-$timestamp.json" +} + +function ConvertTo-HttpEndpoint { + param([string]$Value) + + if ($Value.StartsWith("http://", [StringComparison]::OrdinalIgnoreCase) -or + $Value.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { + return $Value + } + + return "http://$Value" +} + +function ConvertTo-HostEndpoint { + param([string]$Value) + + $hostValue = $Value + if ($hostValue.StartsWith("http://", [StringComparison]::OrdinalIgnoreCase)) { + $hostValue = $hostValue.Substring(7) + } elseif ($hostValue.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { + $hostValue = $hostValue.Substring(8) + } + + return $hostValue.TrimEnd("/") +} + +function Join-CommandLine { + param( + [string]$FilePath, + [string[]]$Arguments + ) + + $parts = @($FilePath) + $Arguments + return ($parts | ForEach-Object { ConvertTo-NativeArgument -Value $_ }) -join " " +} + +function ConvertTo-NativeArgument { + param([string]$Value) + + if ($Value -notmatch '[\s"]') { + return $Value + } + + return '"' + $Value.Replace('\', '\\').Replace('"', '\"') + '"' +} + +function Invoke-NativeCommand { + param( + [string]$FilePath, + [string[]]$Arguments, + [string]$WorkingDirectory, + [hashtable]$Environment = @{} + ) + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo.FileName = $FilePath + $process.StartInfo.Arguments = ($Arguments | ForEach-Object { + ConvertTo-NativeArgument -Value $_ + }) -join " " + $process.StartInfo.WorkingDirectory = $WorkingDirectory + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $process.StartInfo.UseShellExecute = $false + foreach ($entry in $Environment.GetEnumerator()) { + $process.StartInfo.Environment[$entry.Key] = [string]$entry.Value + } + + $commandLine = Join-CommandLine -FilePath $FilePath -Arguments $Arguments + if ($DryRun) { + Write-Host "[dry-run] $commandLine" + return [pscustomobject]@{ + commandLine = $commandLine + exitCode = 0 + stdout = "{}" + stderr = "" + } + } + + [void]$process.Start() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + $result = [pscustomobject]@{ + commandLine = $commandLine + exitCode = $process.ExitCode + stdout = $stdout + stderr = $stderr + } + + if ($result.exitCode -ne 0) { + throw "Command failed with exit code $($result.exitCode): $commandLine`n$stderr`n$stdout" + } + + return $result +} + +function Read-JsonObject { + param([string]$Text) + + if ([string]::IsNullOrWhiteSpace($Text)) { + return $null + } + + try { + return $Text | ConvertFrom-Json + } catch { + $jsonLines = @() + foreach ($line in ($Text -split "`r?`n")) { + $trimmed = $line.Trim() + if ($trimmed.StartsWith("{") -and $trimmed.EndsWith("}")) { + $jsonLines += ($trimmed | ConvertFrom-Json) + } + } + return $jsonLines + } +} + +function Get-OpenSessionId { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return $Json.sessionId } + "go" { return $Json.reply.sessionId } + "rust" { return $Json.sessionId } + "python" { return $Json.sessionId } + "java" { return $Json.reply.sessionId } + } +} + +function Get-ServerHandle { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return [int]$Json.register.serverHandle } + "go" { return [int]$Json.reply.register.serverHandle } + "rust" { return [int]$Json.serverHandle } + "python" { return [int]$Json.serverHandle } + "java" { return [int]$Json.reply.register.serverHandle } + } +} + +function Get-ItemHandle { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return [int]$Json.addItem.itemHandle } + "go" { return [int]$Json.reply.addItem.itemHandle } + "rust" { return [int]$Json.itemHandle } + "python" { return [int]$Json.itemHandle } + "java" { return [int]$Json.reply.addItem.itemHandle } + } +} + +function Get-StreamEventCount { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return @($Json.events).Count } + "go" { return @($Json).Count } + "rust" { return [int]$Json.eventCount } + "python" { return @($Json.events).Count } + "java" { return @($Json).Count } + } +} + +function Get-ClientCommand { + param( + [string]$Client, + [string]$Operation, + [hashtable]$Values + ) + + $httpEndpoint = ConvertTo-HttpEndpoint -Value $Endpoint + $hostEndpoint = ConvertTo-HostEndpoint -Value $Endpoint + $clientName = "mxgw-$Client-e2e" + + switch ($Client) { + "dotnet" { + $arguments = @( + "run", "--project", "clients/dotnet/MxGateway.Client.Cli", "--", + $Operation, + "--endpoint", $httpEndpoint, + "--api-key-env", $ApiKeyEnv, + "--timeout", "60s", + "--json" + ) + if ($Operation -eq "open-session") { + $arguments += @("--client-name", $clientName) + } elseif ($Operation -eq "register") { + $arguments += @("--session-id", $Values.sessionId, "--client-name", $clientName) + } elseif ($Operation -eq "add-item") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) + } elseif ($Operation -eq "advise") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") + } elseif ($Operation -eq "stream-events") { + $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit") + } elseif ($Operation -eq "close-session") { + $arguments += @("--session-id", $Values.sessionId) + } + return [pscustomobject]@{ file = "dotnet"; args = $arguments; cwd = $repoRoot; env = @{} } + } + "go" { + $arguments = @( + "run", "./cmd/mxgw-go", $Operation, + "-endpoint", $hostEndpoint, + "-api-key-env", $ApiKeyEnv, + "-plaintext", + "-json" + ) + if ($Operation -eq "open-session") { + $arguments += @("-client-session-name", $clientName) + } elseif ($Operation -eq "register") { + $arguments += @("-session-id", $Values.sessionId, "-client-name", $clientName) + } elseif ($Operation -eq "add-item") { + $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item", $Values.item) + } elseif ($Operation -eq "advise") { + $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)") + } elseif ($Operation -eq "stream-events") { + $arguments += @("-session-id", $Values.sessionId, "-limit", "$EventLimit") + } elseif ($Operation -eq "close-session") { + $arguments += @("-session-id", $Values.sessionId) + } + return [pscustomobject]@{ file = "go"; args = $arguments; cwd = (Join-Path $repoRoot "clients/go"); env = @{} } + } + "rust" { + $arguments = @( + "run", "-p", "mxgw-cli", "--", $Operation, + "--endpoint", $httpEndpoint, + "--api-key-env", $ApiKeyEnv, + "--json" + ) + if ($Operation -eq "open-session") { + $arguments += @("--client-name", $clientName) + } elseif ($Operation -eq "register") { + $arguments += @("--session-id", $Values.sessionId, "--client-name", $clientName) + } elseif ($Operation -eq "add-item") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) + } elseif ($Operation -eq "advise") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") + } elseif ($Operation -eq "stream-events") { + $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit") + } elseif ($Operation -eq "close-session") { + $arguments += @("--session-id", $Values.sessionId) + } + return [pscustomobject]@{ file = "cargo"; args = $arguments; cwd = (Join-Path $repoRoot "clients/rust"); env = @{} } + } + "python" { + $arguments = @( + "-m", "mxgateway_cli", $Operation, + "--endpoint", $hostEndpoint, + "--api-key-env", $ApiKeyEnv, + "--plaintext", + "--json" + ) + if ($Operation -eq "open-session") { + $arguments += @("--client-name", $clientName) + } elseif ($Operation -eq "register") { + $arguments += @("--session-id", $Values.sessionId, "--client-name", $clientName) + } elseif ($Operation -eq "add-item") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) + } elseif ($Operation -eq "advise") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") + } elseif ($Operation -eq "stream-events") { + $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit", "--timeout", "15") + } elseif ($Operation -eq "close-session") { + $arguments += @("--session-id", $Values.sessionId) + } + $env = @{ + PYTHONPATH = Join-Path $repoRoot "clients/python/src" + } + return [pscustomobject]@{ file = "py"; args = @("-3.12") + $arguments; cwd = (Join-Path $repoRoot "clients/python"); env = $env } + } + "java" { + $cliArgs = @( + $Operation, + "--endpoint", $hostEndpoint, + "--api-key-env", $ApiKeyEnv, + "--plaintext", + "--json" + ) + if ($Operation -eq "open-session") { + $cliArgs += @("--client-session-name", $clientName) + } elseif ($Operation -eq "register") { + $cliArgs += @("--session-id", $Values.sessionId, "--client-name", $clientName) + } elseif ($Operation -eq "add-item") { + $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) + } elseif ($Operation -eq "advise") { + $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") + } elseif ($Operation -eq "stream-events") { + $cliArgs += @("--session-id", $Values.sessionId, "--limit", "$EventLimit") + } elseif ($Operation -eq "close-session") { + $cliArgs += @("--session-id", $Values.sessionId) + } + $arguments = @("--quiet", ":mxgateway-cli:run", "--args=$($cliArgs -join ' ')") + return [pscustomobject]@{ file = "gradle"; args = $arguments; cwd = (Join-Path $repoRoot "clients/java"); env = @{} } + } + } +} + +function Invoke-ClientOperation { + param( + [string]$Client, + [string]$Operation, + [hashtable]$Values = @{} + ) + + $command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values + $result = Invoke-NativeCommand ` + -FilePath $command.file ` + -Arguments $command.args ` + -WorkingDirectory $command.cwd ` + -Environment $command.env + if ($DryRun) { + switch ($Operation) { + "open-session" { return [pscustomobject]@{ sessionId = "dry-run-session-$Client"; reply = [pscustomobject]@{ sessionId = "dry-run-session-$Client" } } } + "register" { return [pscustomobject]@{ serverHandle = 1; register = [pscustomobject]@{ serverHandle = 1 }; reply = [pscustomobject]@{ register = [pscustomobject]@{ serverHandle = 1 } } } } + "add-item" { return [pscustomobject]@{ itemHandle = 1; addItem = [pscustomobject]@{ itemHandle = 1 }; reply = [pscustomobject]@{ addItem = [pscustomobject]@{ itemHandle = 1 } } } } + "stream-events" { return [pscustomobject]@{ eventCount = 1; events = @([pscustomobject]@{ workerSequence = 1 }) } } + default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } } + } + } + return Read-JsonObject -Text $result.stdout +} + +$discoveryJson = & $discoveryScript ` + -MachineStart $MachineStart ` + -MachineEnd $MachineEnd ` + -Attributes $Attributes ` + -SqlServer $SqlServer ` + -Database $Database ` + -Json +$convertedTags = $discoveryJson | ConvertFrom-Json +$tags = @($convertedTags) +if ($tags.Count -eq 1 -and $tags[0] -is [System.Array]) { + $tags = @($tags[0]) +} +$expectedTagCount = (($MachineEnd - $MachineStart + 1) * $Attributes.Count) + +if ($tags.Count -ne $expectedTagCount) { + $found = $tags | Group-Object -Property tagName | ForEach-Object { + "$($_.Name)=$($_.Count)" + } + throw "Expected $expectedTagCount discovered test tags, found $($tags.Count): $($found -join ', ')" +} + +$run = [ordered]@{ + schemaVersion = 1 + endpoint = $Endpoint + apiKeyEnv = $ApiKeyEnv + machineStart = $MachineStart + machineEnd = $MachineEnd + attributes = $Attributes + eventLimit = $EventLimit + skipStream = [bool]$SkipStream + startedAt = (Get-Date).ToUniversalTime().ToString("O") + discoveredTags = $tags + clients = @() +} + +$hadFailure = $false + +foreach ($client in $Clients) { + Write-Host "Running $client client e2e flow against $($tags.Count) discovered tags." + $sessionId = $null + $serverHandle = $null + $clientResult = [ordered]@{ + language = $client + sessionId = $null + serverHandle = $null + addedItems = @() + eventCount = 0 + closed = $false + error = $null + } + + try { + $openJson = Invoke-ClientOperation -Client $client -Operation "open-session" + $sessionId = Get-OpenSessionId -Client $client -Json $openJson + if ([string]::IsNullOrWhiteSpace($sessionId)) { + throw "The $client open-session command did not return a session id." + } + $clientResult.sessionId = $sessionId + + $registerJson = Invoke-ClientOperation -Client $client -Operation "register" -Values @{ + sessionId = $sessionId + } + $serverHandle = Get-ServerHandle -Client $client -Json $registerJson + $clientResult.serverHandle = $serverHandle + + foreach ($tag in $tags) { + $addJson = Invoke-ClientOperation -Client $client -Operation "add-item" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + item = $tag.fullTagReference + } + $itemHandle = Get-ItemHandle -Client $client -Json $addJson + Invoke-ClientOperation -Client $client -Operation "advise" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + itemHandle = $itemHandle + } | Out-Null + + $clientResult.addedItems += [ordered]@{ + tagName = $tag.tagName + attributeName = $tag.attributeName + fullTagReference = $tag.fullTagReference + itemHandle = $itemHandle + protectedWriteRequired = $tag.attributeName -eq "ProtectedValue" + } + } + + if (-not $SkipStream) { + $streamJson = Invoke-ClientOperation -Client $client -Operation "stream-events" -Values @{ + sessionId = $sessionId + } + $clientResult.eventCount = Get-StreamEventCount -Client $client -Json $streamJson + if ($clientResult.eventCount -lt 1) { + throw "The $client stream-events command returned no events." + } + } + } catch { + $hadFailure = $true + $clientResult.error = $_.Exception.Message + Write-Warning "$client e2e flow failed: $($clientResult.error)" + } finally { + if (-not [string]::IsNullOrWhiteSpace($sessionId)) { + try { + Invoke-ClientOperation -Client $client -Operation "close-session" -Values @{ + sessionId = $sessionId + } | Out-Null + $clientResult.closed = $true + } catch { + $hadFailure = $true + $clientResult.error = "$($clientResult.error) close-session failed: $($_.Exception.Message)" + } + } + } + + $run.clients += $clientResult +} + +$run.completedAt = (Get-Date).ToUniversalTime().ToString("O") +$run.success = -not $hadFailure + +if (-not $DryRun) { + $reportDirectory = Split-Path -Parent $ReportPath + if (-not [string]::IsNullOrWhiteSpace($reportDirectory)) { + New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null + } + $run | ConvertTo-Json -Depth 8 | Set-Content -Path $ReportPath -Encoding UTF8 + Write-Host "Wrote e2e report to $ReportPath" +} + +if ($hadFailure) { + exit 1 +} diff --git a/src/MxGateway.Server/Configuration/DashboardOptions.cs b/src/MxGateway.Server/Configuration/DashboardOptions.cs index 5c19e47..668f965 100644 --- a/src/MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/MxGateway.Server/Configuration/DashboardOptions.cs @@ -8,7 +8,7 @@ public sealed class DashboardOptions public bool RequireAdminScope { get; init; } = true; - public bool AllowAnonymousLocalhost { get; init; } + public bool AllowAnonymousLocalhost { get; init; } = true; public int SnapshotIntervalMilliseconds { get; init; } = 1_000; diff --git a/src/MxGateway.Server/Dashboard/Components/App.razor b/src/MxGateway.Server/Dashboard/Components/App.razor index c789beb..4468c4c 100644 --- a/src/MxGateway.Server/Dashboard/Components/App.razor +++ b/src/MxGateway.Server/Dashboard/Components/App.razor @@ -13,7 +13,7 @@ - + diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor b/src/MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor index 27f8208..7382c45 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor @@ -1,4 +1,5 @@ @page "/" +@page "/dashboard/" @inherits DashboardPageBase MXAccess Gateway Dashboard diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor index d63d205..6bff2c8 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor @@ -1,4 +1,5 @@ @page "/events" +@page "/dashboard/events" @inherits DashboardPageBase Dashboard Events diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index 3c3560b..150bc60 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -1,4 +1,5 @@ @page "/sessions/{SessionId}" +@page "/dashboard/sessions/{SessionId}" @inherits DashboardPageBase Dashboard Session diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor index 3ad0079..b9456a0 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor @@ -1,4 +1,5 @@ @page "/sessions" +@page "/dashboard/sessions" @inherits DashboardPageBase Dashboard Sessions diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor index c9c5449..46ddfdd 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor @@ -1,4 +1,5 @@ @page "/settings" +@page "/dashboard/settings" @inherits DashboardPageBase Dashboard Settings diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor index 4811852..80f8182 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor @@ -1,4 +1,5 @@ @page "/workers" +@page "/dashboard/workers" @inherits DashboardPageBase Dashboard Workers diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 67a0c1b..546a003 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Hosting.StaticWebAssets; using MxGateway.Contracts; using MxGateway.Server.Configuration; using MxGateway.Server.Dashboard; @@ -13,6 +14,8 @@ namespace MxGateway.Server; public static class GatewayApplication { + private const string StaticAssetsManifestFileName = "MxGateway.Server.staticwebassets.endpoints.json"; + public static WebApplication Build(string[] args) { WebApplicationBuilder builder = CreateBuilder(args); @@ -30,7 +33,12 @@ public static class GatewayApplication public static WebApplicationBuilder CreateBuilder(string[] args) { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = args, + ContentRootPath = ResolveContentRootPath(), + }); + StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); builder.Services.AddGatewayConfiguration(); builder.Services.AddSqliteAuthStore(); @@ -47,8 +55,65 @@ public static class GatewayApplication return builder; } + private static string ResolveContentRootPath() + { + string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT"); + if (!string.IsNullOrWhiteSpace(configuredContentRootPath) + && IsServerContentRoot(configuredContentRootPath)) + { + return configuredContentRootPath; + } + + string currentDirectory = Environment.CurrentDirectory; + if (IsServerContentRoot(currentDirectory)) + { + return currentDirectory; + } + + string baseDirectory = AppContext.BaseDirectory; + if (IsServerContentRoot(baseDirectory)) + { + return baseDirectory; + } + + string? discoveredContentRootPath = DiscoverServerContentRoot(currentDirectory) + ?? DiscoverServerContentRoot(baseDirectory); + + return discoveredContentRootPath ?? baseDirectory; + } + + private static string? DiscoverServerContentRoot(string startPath) + { + DirectoryInfo? directory = new(startPath); + while (directory is not null) + { + if (IsServerContentRoot(directory.FullName)) + { + return directory.FullName; + } + + string serverProjectPath = Path.Combine(directory.FullName, "src", "MxGateway.Server"); + if (IsServerContentRoot(serverProjectPath)) + { + return serverProjectPath; + } + + directory = directory.Parent; + } + + return null; + } + + private static bool IsServerContentRoot(string path) + { + return File.Exists(Path.Combine(path, "appsettings.json")) + && Directory.Exists(Path.Combine(path, "wwwroot")); + } + public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints) { + endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); + endpoints.MapGet("/", () => Results.Redirect("/health/live")); endpoints.MapGet( @@ -64,4 +129,11 @@ public static class GatewayApplication return endpoints; } + + private static string ResolveStaticAssetsManifestPath() + { + string manifestPath = Path.Combine(AppContext.BaseDirectory, StaticAssetsManifestFileName); + + return File.Exists(manifestPath) ? manifestPath : StaticAssetsManifestFileName; + } } diff --git a/src/MxGateway.Server/appsettings.json b/src/MxGateway.Server/appsettings.json index 4786fe6..5998298 100644 --- a/src/MxGateway.Server/appsettings.json +++ b/src/MxGateway.Server/appsettings.json @@ -35,7 +35,7 @@ "Enabled": true, "PathBase": "/dashboard", "RequireAdminScope": true, - "AllowAnonymousLocalhost": false, + "AllowAnonymousLocalhost": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, diff --git a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs index 3a47905..56fb92c 100644 --- a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -35,7 +35,7 @@ public sealed class GatewayOptionsTests Assert.True(options.Dashboard.Enabled); Assert.Equal("/dashboard", options.Dashboard.PathBase); Assert.True(options.Dashboard.RequireAdminScope); - Assert.False(options.Dashboard.AllowAnonymousLocalhost); + Assert.True(options.Dashboard.AllowAnonymousLocalhost); Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds); Assert.Equal(100, options.Dashboard.RecentFaultLimit); Assert.Equal(200, options.Dashboard.RecentSessionLimit);